diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 65c3152..e5e68da 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -7,26 +7,26 @@ import ( "os" "echsen.club/radio/internal/config" - "echsen.club/radio/internal/db" "echsen.club/radio/internal/state" + "echsen.club/radio/internal/store" ) func main() { config := config.Setup() - database, err := db.New(config.PostgresUrl) + database, err := store.NewDatabase(config.PostgresUrl) if err != nil { slog.Error("Couldnt connect to database", "error", err) os.Exit(1) } defer database.Close() - if err := db.RunMigrations(database); err != nil { + if err := store.RunMigrations(database); err != nil { slog.Error("Migration failed", "error", err) os.Exit(1) } - state := state.New(database, config) + state := state.New(database, &config) log.Println("Server starting on port ", config.Port) diff --git a/backend/go.mod b/backend/go.mod index 8c671f7..bd0c965 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -26,6 +26,7 @@ require ( github.com/google/cel-go v0.26.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 27f6b6b..e10b1f2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -8,6 +8,7 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a h1:dIdcLbck6W67B5JFMewU5Dba1yKZA3MsT67i4No/zh0= @@ -27,6 +28,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= @@ -41,6 +43,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -49,6 +53,7 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= diff --git a/backend/internal/channel/create.go b/backend/internal/channel/create.go new file mode 100644 index 0000000..0b971ac --- /dev/null +++ b/backend/internal/channel/create.go @@ -0,0 +1,66 @@ +package channel + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "connectrpc.com/connect" + radiov1 "echsen.club/radio/gen/radio/v1" + s "echsen.club/radio/internal/store" + "echsen.club/radio/internal/user" + "github.com/google/uuid" +) + +func (handler *ChannelHandler) CreateChannel(ctx context.Context, req *radiov1.CreateChannelRequest) (*radiov1.CreateChannelResponse, error) { + user, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + c, err := handler.store.CreateChannel(ctx, user.ID, req.Frequency, "insane Description") + if err != nil { + if errors.Is(err, s.ErrFrequencyExists) { + return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("Frequency already exists in database")) + } else { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Couldnt insert channel into database")) + } + } + + channelProto := &radiov1.Channel{Id: c.ID.String(), Frequency: c.Frequency, Description: c.Description} + return &radiov1.CreateChannelResponse{Channel: channelProto}, nil +} + +func (handler *ChannelHandler) DeleteChannel(ctx context.Context, req *radiov1.DeleteChannelRequest) (*radiov1.DeleteChannelResponse, error) { + user, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + uid, err := uuid.Parse(req.Id) + if err != nil { + slog.Info("delete channel request with invalid uuid", "uuid", req.Id) + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("not a valid uuid")) + } + + channel, err := handler.store.GetChannel(ctx, uid) + if err != nil { + if errors.Is(err, s.ErrChannelDoesntExist) { + slog.Info("channel wasnt found", "channel", uid) + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("channel doesnt exist")) + } else { + slog.Error("coudlnt get channel", "channel", uid) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("couldnt get channel")) + } + } + + if channel.OwnerID != user.ID { + slog.Info("channel delete unauthorized", "user", user.ID, "channel", channel.ID) + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("no permission")) + } + + handler.store.DeleteChannel(ctx, channel.ID) + + return &radiov1.DeleteChannelResponse{}, nil +} diff --git a/backend/internal/channel/handler.go b/backend/internal/channel/handler.go index 0dc01a9..2b36902 100644 --- a/backend/internal/channel/handler.go +++ b/backend/internal/channel/handler.go @@ -1,77 +1,15 @@ package channel import ( - "context" - "errors" - "fmt" - "log/slog" - - "connectrpc.com/connect" - radiov1 "echsen.club/radio/gen/radio/v1" - "echsen.club/radio/internal/user" - "github.com/google/uuid" + s "echsen.club/radio/internal/store" ) type ChannelHandler struct { - store *Store + store *s.Store } -func NewHandler(store *Store) *ChannelHandler { +func NewHandler(store *s.Store) *ChannelHandler { return &ChannelHandler{ store: store, } } - -func (handler *ChannelHandler) CreateChannel(ctx context.Context, req *radiov1.CreateChannelRequest) (*radiov1.CreateChannelResponse, error) { - user, ok := user.FromContext(ctx) - if !ok { - slog.Error("User missing from context in protected route") - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) - } - - c, err := handler.store.CreateChannel(ctx, user.ID, req.Frequency, "insane Description") - if err != nil { - if errors.Is(err, ErrFrequencyExists) { - return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("Frequency already exists in database")) - } else { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Couldnt insert channel into database")) - } - } - - channelProto := &radiov1.Channel{Id: c.ID.String(), Frequency: c.Frequency, Description: c.Description} - return &radiov1.CreateChannelResponse{Channel: channelProto}, nil -} - -func (handler *ChannelHandler) DeleteChannel(ctx context.Context, req *radiov1.DeleteChannelRequest) (*radiov1.DeleteChannelResponse, error) { - user, ok := user.FromContext(ctx) - if !ok { - slog.Error("User missing from context in protected route") - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) - } - - uid, err := uuid.Parse(req.Id) - if err != nil { - slog.Info("delete channel request with invalid uuid", "uuid", req.Id) - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("not a valid uuid")) - } - - channel, err := handler.store.GetChannel(ctx, uid) - if err != nil { - if errors.Is(err, ErrChannelDoesntExist) { - slog.Info("channel wasnt found", "channel", uid) - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("channel doesnt exist")) - } else { - slog.Error("coudlnt get channel", "channel", uid) - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("couldnt get channel")) - } - } - - if channel.OwnerID != user.ID { - slog.Info("channel delete unauthorized", "user", user.ID, "channel", channel.ID) - return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("no permission")) - } - - handler.store.DeleteChannel(ctx, channel.ID) - - return &radiov1.DeleteChannelResponse{}, nil -} diff --git a/backend/internal/channel/model.go b/backend/internal/models/channel.go similarity index 92% rename from backend/internal/channel/model.go rename to backend/internal/models/channel.go index ead75b1..8bee347 100644 --- a/backend/internal/channel/model.go +++ b/backend/internal/models/channel.go @@ -1,4 +1,4 @@ -package channel +package models import ( "time" diff --git a/backend/internal/models/song.go b/backend/internal/models/song.go new file mode 100644 index 0000000..e745d6b --- /dev/null +++ b/backend/internal/models/song.go @@ -0,0 +1,16 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Song struct { + ID uuid.UUID + OwnerID uuid.UUID + Filename string + Name string + CreatedAt time.Time + ModifiedAt time.Time +} diff --git a/backend/internal/user/model.go b/backend/internal/models/user.go similarity index 67% rename from backend/internal/user/model.go rename to backend/internal/models/user.go index 5b1c3cf..b63d967 100644 --- a/backend/internal/user/model.go +++ b/backend/internal/models/user.go @@ -1,8 +1,6 @@ -package user +package models -import ( - "github.com/google/uuid" -) +import "github.com/google/uuid" type User struct { ID uuid.UUID diff --git a/backend/internal/oauth/oidc.go b/backend/internal/oauth/oauth.go similarity index 96% rename from backend/internal/oauth/oidc.go rename to backend/internal/oauth/oauth.go index 0064bb2..d9d428a 100644 --- a/backend/internal/oauth/oidc.go +++ b/backend/internal/oauth/oauth.go @@ -3,7 +3,6 @@ package oauth import ( "context" "crypto/rand" - "database/sql" "encoding/base64" "fmt" "io" @@ -16,6 +15,7 @@ import ( "github.com/antonlindstrom/pgstore" "github.com/coreos/go-oidc/v3/oidc" "github.com/gorilla/sessions" + "github.com/jmoiron/sqlx" "golang.org/x/oauth2" ) @@ -26,7 +26,7 @@ type Oauth struct { LogoutUrl string } -func New(db *sql.DB, config config.AuthConfig) (*Oauth, error) { +func New(db *sqlx.DB, config config.AuthConfig) (*Oauth, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -50,7 +50,7 @@ func New(db *sql.DB, config config.AuthConfig) (*Oauth, error) { return nil, fmt.Errorf("failed to extract provider claims: %v", err) } - store, err := pgstore.NewPGStoreFromPool(db, []byte(config.SessionKey)) + store, err := pgstore.NewPGStoreFromPool(db.DB, []byte(config.SessionKey)) if err != nil { return nil, fmt.Errorf("could not create pgstore: %v", err) } diff --git a/backend/internal/song/channel.go b/backend/internal/song/channel.go new file mode 100644 index 0000000..91ee792 --- /dev/null +++ b/backend/internal/song/channel.go @@ -0,0 +1,50 @@ +package song + +import ( + "context" + "errors" + "log/slog" + + "connectrpc.com/connect" + radiov1 "echsen.club/radio/gen/radio/v1" + s "echsen.club/radio/internal/store" + "echsen.club/radio/internal/user" + "github.com/google/uuid" +) + +func (handler *SongHandler) AddSongToChannel(ctx context.Context, req *radiov1.AddSongToChannelRequest) (*radiov1.Empty, error) { + user, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + channelId, err := uuid.Parse(req.ChannelId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid channel id")) + } + + songId, err := uuid.Parse(req.SongId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid song id")) + } + + c, err := handler.store.GetChannel(ctx, channelId) + if err != nil { + if errors.Is(err, s.ErrChannelDoesntExist) { + return nil, connect.NewError(connect.CodeNotFound, errors.New("channel not found")) + } + slog.Error("Failed to get channel", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get channel")) + } + + if c.OwnerID != user.ID { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("user is not owner of channel")) + } + + if errPtr := handler.store.AddSongToChannel(ctx, songId, channelId); errPtr != nil { + slog.Error("Failed to add song to channel", "error", *errPtr) + return nil, connect.NewError(connect.CodeInternal, *errPtr) + } + + return &radiov1.Empty{}, nil +} diff --git a/backend/internal/song/create.go b/backend/internal/song/create.go new file mode 100644 index 0000000..96cf2f2 --- /dev/null +++ b/backend/internal/song/create.go @@ -0,0 +1,108 @@ +package song + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + + "connectrpc.com/connect" + radiov1 "echsen.club/radio/gen/radio/v1" + "echsen.club/radio/internal/user" + "github.com/google/uuid" +) + +func (handler *SongHandler) UploadSong(ctx context.Context, stream *connect.ClientStream[radiov1.UploadRequest]) (*connect.Response[radiov1.UploadResponse], error) { + user, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + var destFile *os.File + var totalBytes int64 + var safeName string + var success bool + + defer func() { + if !success && safeName != "" { + os.Remove(filepath.Join(handler.config.SongDirectory, safeName)) + } + }() + + for stream.Receive() { + msg := stream.Msg() + + switch x := msg.Data.(type) { + case *radiov1.UploadRequest_Filename: + safeName = filepath.Base(x.Filename) + f, err := os.Create(filepath.Join(handler.config.SongDirectory, safeName)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + destFile = f + defer destFile.Close() + case *radiov1.UploadRequest_Chunk: + if destFile == nil { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("filename must be sent first")) + } + n, err := destFile.Write(x.Chunk) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + totalBytes += int64(n) + } + } + + if err := stream.Err(); err != nil { + return nil, connect.NewError(connect.CodeUnknown, err) + } + + song, err := handler.store.CreateSong(ctx, user.ID, safeName) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt add song to database")) + } + + res := connect.NewResponse(&radiov1.UploadResponse{ + SongId: song.ID.String(), + Size: totalBytes, + }) + + return res, nil +} + +func (handler *SongHandler) DeleteSong(ctx context.Context, req *radiov1.DeleteSongRequest) (*radiov1.DeleteSongResponse, error) { + user, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + song_id, err := uuid.Parse(req.SongId) + if err != nil { + slog.Info("song id not correct") + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid channel id")) + } + + song, err := handler.store.GetSong(ctx, song_id) + if err != nil { + slog.Info("couldnt find song") + return nil, connect.NewError(connect.CodeNotFound, errors.New("couldnt find song")) + } + + if song.OwnerID != user.ID { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("not authorized")) + } + + err = handler.store.DeleteSong(ctx, song_id) + if err != nil { + slog.Error("couldnt delete song from database") + return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt delete song")) + } + + err = os.Remove(filepath.Join(handler.config.SongDirectory, song.Filename)) + if err != nil { + slog.Error("couldnt delete song from filesystem") + } + + return &radiov1.DeleteSongResponse{}, nil +} diff --git a/backend/internal/song/handler.go b/backend/internal/song/handler.go index 3ca1e21..31c4a5e 100644 --- a/backend/internal/song/handler.go +++ b/backend/internal/song/handler.go @@ -1,158 +1,15 @@ package song import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - - "connectrpc.com/connect" - radiov1 "echsen.club/radio/gen/radio/v1" - "echsen.club/radio/internal/channel" - "echsen.club/radio/internal/user" - "github.com/google/uuid" + "echsen.club/radio/internal/config" + s "echsen.club/radio/internal/store" ) type SongHandler struct { - store *Store -} - -func NewHandler(store *Store) *SongHandler { - return &SongHandler{store} -} - -func (handler *SongHandler) UploadSong(ctx context.Context, stream *connect.ClientStream[radiov1.UploadRequest]) (*connect.Response[radiov1.UploadResponse], error) { - user, ok := user.FromContext(ctx) - if !ok { - slog.Error("User missing from context in protected route") - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) - } - - var destFile *os.File - var totalBytes int64 - var safeName string - var success bool - - defer func() { - if !success && safeName != "" { - os.Remove(filepath.Join(handler.store.config.SongDirectory, safeName)) - } - }() - - for stream.Receive() { - msg := stream.Msg() - - switch x := msg.Data.(type) { - case *radiov1.UploadRequest_Filename: - safeName = filepath.Base(x.Filename) - f, err := os.Create(filepath.Join(handler.store.config.SongDirectory, safeName)) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - destFile = f - defer destFile.Close() - case *radiov1.UploadRequest_Chunk: - if destFile == nil { - return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("filename must be sent first")) - } - n, err := destFile.Write(x.Chunk) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - totalBytes += int64(n) - } - } - - if err := stream.Err(); err != nil { - return nil, connect.NewError(connect.CodeUnknown, err) - } - - song, err := handler.store.CreateSong(ctx, user.ID, safeName) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt add song to database")) - } - - res := connect.NewResponse(&radiov1.UploadResponse{ - SongId: song.id.String(), - Size: totalBytes, - }) - - return res, nil + store *s.Store + config *config.Config } -func (handler *SongHandler) AddSongToChannel(ctx context.Context, req *radiov1.AddSongToChannelRequest) (*radiov1.Empty, error) { - user, ok := user.FromContext(ctx) - if !ok { - slog.Error("User missing from context in protected route") - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) - } - - channelId, err := uuid.Parse(req.ChannelId) - if err != nil { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid channel id")) - } - - songId, err := uuid.Parse(req.SongId) - if err != nil { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid song id")) - } - - c, err := handler.store.channelStore.GetChannel(ctx, channelId) - if err != nil { - if errors.Is(err, channel.ErrChannelDoesntExist) { - return nil, connect.NewError(connect.CodeNotFound, errors.New("channel not found")) - } - slog.Error("Failed to get channel", "error", err) - return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get channel")) - } - - if c.OwnerID != user.ID { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("user is not owner of channel")) - } - - if errPtr := handler.store.AddSongToChannel(ctx, songId, channelId); errPtr != nil { - slog.Error("Failed to add song to channel", "error", *errPtr) - return nil, connect.NewError(connect.CodeInternal, *errPtr) - } - - return &radiov1.Empty{}, nil -} - -func (handler *SongHandler) DeleteSong(ctx context.Context, req *radiov1.DeleteSongRequest) (*radiov1.DeleteSongResponse, error) { - user, ok := user.FromContext(ctx) - if !ok { - slog.Info("User missing from context in protected route") - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) - } - - song_id, err := uuid.Parse(req.SongId) - if err != nil { - slog.Info("song id not correct") - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid channel id")) - } - - song, err := handler.store.GetSong(ctx, song_id) - if err != nil { - slog.Info("couldnt find song") - return nil, connect.NewError(connect.CodeNotFound, errors.New("couldnt find song")) - } - - if song.owner_id != user.ID { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("not authorized")) - } - - err = handler.store.DeleteSong(ctx, song_id) - if err != nil { - slog.Error("couldnt delete song from database") - return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt delete song")) - } - - err = os.Remove(filepath.Join(handler.store.config.SongDirectory, song.filename)) - if err != nil { - slog.Error("couldnt delete song from filesystem") - } - - return &radiov1.DeleteSongResponse{}, nil +func NewHandler(store *s.Store, config *config.Config) *SongHandler { + return &SongHandler{store, config} } diff --git a/backend/internal/song/model.go b/backend/internal/song/model.go deleted file mode 100644 index a23b8e6..0000000 --- a/backend/internal/song/model.go +++ /dev/null @@ -1,16 +0,0 @@ -package song - -import ( - "time" - - "github.com/google/uuid" -) - -type Song struct { - id uuid.UUID - owner_id uuid.UUID - filename string - name string - createdAt time.Time - modifiedAt time.Time -} diff --git a/backend/internal/state/middleware.go b/backend/internal/state/middleware.go index c3dad4a..aae02c6 100644 --- a/backend/internal/state/middleware.go +++ b/backend/internal/state/middleware.go @@ -50,7 +50,7 @@ func (state *State) AuthMiddleware(next http.Handler) http.Handler { return } - u, err := state.userStore.GetByID(r.Context(), userID) + u, err := state.store.GetByID(r.Context(), userID) if err != nil { slog.Warn("Session user not found in DB", "user_id", userID) unauthorized() @@ -64,7 +64,7 @@ func (state *State) AuthMiddleware(next http.Handler) http.Handler { } func (state *State) handleAuthSuccess(w http.ResponseWriter, r *http.Request, claims oauth.UserClaims) { - userID, err := state.userStore.Upsert(r.Context(), claims.Subject, claims.Email, claims.Name) + userID, err := state.store.Upsert(r.Context(), claims.Subject, claims.Email, claims.Name) // upserting on every endpoint may be a little wasteful but this app is made for a versy specific and tiny // audience so it shouldnt matter. If it does end up mattering at some point you know what to change :) if err != nil { diff --git a/backend/internal/state/state.go b/backend/internal/state/state.go index 32829f0..8962ffc 100644 --- a/backend/internal/state/state.go +++ b/backend/internal/state/state.go @@ -1,7 +1,6 @@ package state import ( - "database/sql" "log" "net/http" @@ -11,34 +10,33 @@ import ( "echsen.club/radio/internal/channel" "echsen.club/radio/internal/config" "echsen.club/radio/internal/oauth" - "echsen.club/radio/internal/user" + "echsen.club/radio/internal/store" + "github.com/jmoiron/sqlx" ) type State struct { - db *sql.DB + db *sqlx.DB oauth *oauth.Oauth mux *http.ServeMux + store *store.Store channelHandler *channel.ChannelHandler - userStore *user.Store - channelStore *channel.Store } -func New(db *sql.DB, config config.Config) *State { +func New(db *sqlx.DB, config *config.Config) *State { oauth, err := oauth.New(db, config.AuthConfig) if err != nil { log.Fatalf("Failed to initialize OAuth: %v", err) } - userStore := user.NewStore(db) - channelStore := channel.NewStore(db) - channelHandler := channel.NewHandler(channelStore) + store := store.NewStore(db) + + channelHandler := channel.NewHandler(store) state := &State{ db: db, oauth: oauth, mux: http.NewServeMux(), - userStore: userStore, - channelStore: channelStore, + store: store, channelHandler: channelHandler, } diff --git a/backend/internal/channel/store.go b/backend/internal/store/channel.go similarity index 78% rename from backend/internal/channel/store.go rename to backend/internal/store/channel.go index 334d90c..13351dc 100644 --- a/backend/internal/channel/store.go +++ b/backend/internal/store/channel.go @@ -1,4 +1,4 @@ -package channel +package store import ( "context" @@ -7,23 +7,16 @@ import ( "fmt" "log/slog" + m "echsen.club/radio/internal/models" "github.com/google/uuid" "github.com/lib/pq" ) -type Store struct { - db *sql.DB -} - -func NewStore(db *sql.DB) *Store { - return &Store{db: db} -} - var ErrFrequencyExists = errors.New("a channel with this frequency already exists") var ErrChannelDoesntExist = errors.New("a channel with this id doesnt exist") -func (s *Store) CreateChannel(ctx context.Context, owner_id uuid.UUID, frequency, description string) (*Channel, error) { - var c Channel +func (s *Store) CreateChannel(ctx context.Context, owner_id uuid.UUID, frequency, description string) (*m.Channel, error) { + var c m.Channel query := ` INSERT INTO channels (owner_id, frequency, description) VALUES ($1, $2, $3) @@ -66,8 +59,8 @@ func (s *Store) DeleteChannel(ctx context.Context, id uuid.UUID) error { return nil } -func (s *Store) GetChannel(ctx context.Context, id uuid.UUID) (*Channel, error) { - var c Channel +func (s *Store) GetChannel(ctx context.Context, id uuid.UUID) (*m.Channel, error) { + var c m.Channel query := ` SELECT id, owner_id, frequency, description, created_at, modified_at @@ -93,6 +86,24 @@ func (s *Store) GetChannel(ctx context.Context, id uuid.UUID) (*Channel, error) return &c, nil } +func (s *Store) GetSongsFromChannel(ctx context.Context, id uuid.UUID) (error, error) { + query := ` + SELECT s.id, s.owner_id, s.filename, s.name, s.created_at s.modified_at + FROM songs s + JOIN channel_songs cs ON s.id = cs.song_id + WHERE cs.channel_id = $1 + ` + rows, err := s.db.Query(query, id.String()) + if err != nil { + return nil, err + } + defer rows.Close() + + // var songs []*m.Song + + return nil, nil +} + // func (s *Store) ChannelAssignFrequency(id uuid.UUID, frequency string) error { // query := ` // ` diff --git a/backend/internal/db/migrations.go b/backend/internal/store/migrations.go similarity index 62% rename from backend/internal/db/migrations.go rename to backend/internal/store/migrations.go index 253d424..7dcb3bd 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/store/migrations.go @@ -1,13 +1,12 @@ -package db +package store import ( - "database/sql" - "echsen.club/radio/migrations" + "github.com/jmoiron/sqlx" "github.com/pressly/goose/v3" ) -func RunMigrations(db *sql.DB) error { +func RunMigrations(db *sqlx.DB) error { goose.SetBaseFS(migrations.FS) @@ -15,5 +14,5 @@ func RunMigrations(db *sql.DB) error { return err } - return goose.Up(db, ".") + return goose.Up(db.DB, ".") } diff --git a/backend/internal/song/store.go b/backend/internal/store/song.go similarity index 77% rename from backend/internal/song/store.go rename to backend/internal/store/song.go index c9ee1b1..eb11ecd 100644 --- a/backend/internal/song/store.go +++ b/backend/internal/store/song.go @@ -1,4 +1,4 @@ -package song +package store import ( "context" @@ -7,26 +7,15 @@ import ( "fmt" "log/slog" - "echsen.club/radio/internal/channel" - "echsen.club/radio/internal/config" + m "echsen.club/radio/internal/models" "github.com/google/uuid" "github.com/lib/pq" ) -type Store struct { - db *sql.DB - config *config.Config - channelStore *channel.Store -} - -func NewStore(db *sql.DB, config *config.Config, channelStore *channel.Store) *Store { - return &Store{db, config, channelStore} -} - var ErrSongExists = errors.New("song already exists") -func (store *Store) CreateSong(ctx context.Context, owner_id uuid.UUID, filename string) (*Song, error) { - var song Song +func (store *Store) CreateSong(ctx context.Context, owner_id uuid.UUID, filename string) (*m.Song, error) { + var song m.Song query := ` INSERT INTO songs (owner_id, filename, name) @@ -72,8 +61,8 @@ func (store *Store) DeleteSong(ctx context.Context, song_id uuid.UUID) error { var ErrSongDoesntExist = errors.New("song doesnt exist") -func (store *Store) GetSong(ctx context.Context, song_id uuid.UUID) (*Song, error) { - var s Song +func (store *Store) GetSong(ctx context.Context, song_id uuid.UUID) (*m.Song, error) { + var s m.Song query := ` SELECT id, owner_id, filename, name, created_at, modified_at FROM songs @@ -81,12 +70,12 @@ func (store *Store) GetSong(ctx context.Context, song_id uuid.UUID) (*Song, erro ` err := store.db.QueryRowContext(ctx, query, song_id).Scan( - &s.id, - &s.owner_id, - &s.filename, - &s.name, - &s.createdAt, - &s.modifiedAt, + &s.ID, + &s.OwnerID, + &s.Filename, + &s.Name, + &s.CreatedAt, + &s.ModifiedAt, ) if err != nil { diff --git a/backend/internal/db/db.go b/backend/internal/store/store.go similarity index 55% rename from backend/internal/db/db.go rename to backend/internal/store/store.go index 5e96f5e..029e524 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/store/store.go @@ -1,14 +1,22 @@ -package db +package store import ( - "database/sql" "time" + "github.com/jmoiron/sqlx" _ "github.com/lib/pq" ) -func New(connString string) (*sql.DB, error) { - db, err := sql.Open("postgres", connString) +type Store struct { + db *sqlx.DB +} + +func NewStore(db *sqlx.DB) *Store { + return &Store{db: db} +} + +func NewDatabase(connString string) (*sqlx.DB, error) { + db, err := sqlx.Open("postgres", connString) if err != nil { return nil, err } diff --git a/backend/internal/user/store.go b/backend/internal/store/user.go similarity index 82% rename from backend/internal/user/store.go rename to backend/internal/store/user.go index 3c4109c..33ae650 100644 --- a/backend/internal/user/store.go +++ b/backend/internal/store/user.go @@ -1,23 +1,15 @@ -package user +package store import ( "context" - "database/sql" "fmt" + m "echsen.club/radio/internal/models" "github.com/google/uuid" ) -type Store struct { - db *sql.DB -} - -func NewStore(db *sql.DB) *Store { - return &Store{db: db} -} - -func (s *Store) GetByID(ctx context.Context, id string) (*User, error) { - var u User +func (s *Store) GetByID(ctx context.Context, id string) (*m.User, error) { + var u m.User query := `SELECT id, subject_id, email, display_name FROM users WHERE id = $1` err := s.db.QueryRowContext(ctx, query, id).Scan(&u.ID, &u.SubjectID, &u.Email, &u.DisplayName) diff --git a/backend/internal/user/context.go b/backend/internal/user/context.go index 91dd9cb..be49ba8 100644 --- a/backend/internal/user/context.go +++ b/backend/internal/user/context.go @@ -1,16 +1,33 @@ package user -import "context" +import ( + "context" + "fmt" + "log/slog" + + "connectrpc.com/connect" + m "echsen.club/radio/internal/models" +) type contextKey string const userKey contextKey = "user" -func FromContext(ctx context.Context) (*User, bool) { - u, ok := ctx.Value(userKey).(*User) +func FromContext(ctx context.Context) (*m.User, bool) { + u, ok := ctx.Value(userKey).(*m.User) return u, ok } -func NewContext(ctx context.Context, u *User) context.Context { +func RequireUser(ctx context.Context) (*m.User, error) { + u, ok := FromContext(ctx) + if !ok { + // We log here so you don't have to log in every handler + slog.Error("User missing from context in protected route") + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("user not authenticated")) + } + return u, nil +} + +func NewContext(ctx context.Context, u *m.User) context.Context { return context.WithValue(ctx, userKey, u) }