Skip to content

Commit d8fac26

Browse files
[API-45] Implement stream/download/preview urls (#19)
* [API-45] Implement stream/download/preview urls * Clean up * Add getMyId helper * Make logic for rendezvous a little tighter
1 parent 1272805 commit d8fac26

27 files changed

Lines changed: 244 additions & 68 deletions

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ http://localhost:1323/v2/users/stereosteve
2424

2525
> This will watch sql files + re-run `sqlc generate` + restart server when go files change.
2626
27+
other env vars:
28+
```
29+
delegatePrivateKey: key to sign stream/download requests with
30+
axiomToken: axiom api token to pipe logs to axiom
31+
axiomDataset: axiom dataset name
32+
```
2733

2834
## API diff
2935

30-
test diffs from v1/ endpoints
31-
```
32-
make apidiff
33-
```
36+
http://localhost:1323/apidiff.html
3437

3538
## adminer
3639

api/dbv1/full_tracks.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ type FullTracksParams GetTracksParams
1313
type FullTrack struct {
1414
GetTracksRow
1515

16-
Artwork *SquareImage `json:"artwork"`
17-
UserID string `json:"user_id"`
18-
User FullUser `json:"user"`
16+
Permalink string `json:"permalink"`
17+
IsStreamable bool `json:"is_streamable"`
18+
Artwork *SquareImage `json:"artwork"`
19+
Stream *MediaLink `json:"stream"`
20+
Download *MediaLink `json:"download"`
21+
Preview *MediaLink `json:"preview"`
22+
UserID string `json:"user_id"`
23+
User FullUser `json:"user"`
1924

2025
FolloweeReposts []*FolloweeRepost `json:"followee_reposts"`
2126
FolloweeFavorites []*FolloweeFavorite `json:"followee_favorites"`
@@ -52,9 +57,27 @@ func (q *Queries) FullTracksKeyed(ctx context.Context, arg GetTracksParams) (map
5257
continue
5358
}
5459

60+
// Collect media links
61+
// TODO(API-49): support self-access via grants
62+
// see https://github.com/AudiusProject/audius-protocol/blob/4bd9fe80d8cca519844596061505ad8737579019/packages/discovery-provider/src/queries/query_helpers.py#L905
63+
stream := mediaLink(track.TrackCid.String, track.TrackID, arg.MyID.(int32))
64+
var download *MediaLink
65+
if track.IsDownloadable {
66+
download = mediaLink(track.OrigFileCid.String, track.TrackID, arg.MyID.(int32))
67+
}
68+
var preview *MediaLink
69+
if track.PreviewCid.String != "" {
70+
preview = mediaLink(track.PreviewCid.String, track.TrackID, arg.MyID.(int32))
71+
}
72+
5573
fullTrack := FullTrack{
5674
GetTracksRow: track,
75+
IsStreamable: !track.IsDelete && !user.IsDeactivated,
76+
Permalink: fmt.Sprintf("/%s/%s", user.Handle.String, track.Slug.String),
5777
Artwork: squareImageStruct(track.CoverArtSizes, track.CoverArt),
78+
Stream: stream,
79+
Download: download,
80+
Preview: preview,
5881
User: user,
5982
UserID: user.ID,
6083
FolloweeFavorites: fullFolloweeFavorites(track.FolloweeFavorites),

api/dbv1/full_users.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package dbv1
33
import (
44
"context"
55
"fmt"
6+
"math/rand"
67
"strings"
78

89
"bridgerton.audius.co/rendezvous"
@@ -46,9 +47,9 @@ func (q *Queries) FullUsersKeyed(ctx context.Context, arg GetUsersParams) (map[i
4647

4748
if cid != "" {
4849
// rendezvous for cid
49-
rankedHosts := rendezvous.GlobalHasher.Rank(cid)
50-
first := rankedHosts[0]
51-
rest := rankedHosts[1:3]
50+
ranked := rendezvous.GlobalHasher.Rank(cid)
51+
randIdx := rand.Intn(3)
52+
first, rest := ranked[randIdx], append(ranked[:randIdx], ranked[randIdx+1:]...)[:2]
5253

5354
coverPhoto = &RectangleImage{
5455
X640: fmt.Sprintf("%s/content/%s/640x.jpg", first, cid),
@@ -110,9 +111,9 @@ func squareImageStruct(maybeCids ...pgtype.Text) *SquareImage {
110111
}
111112

112113
// rendezvous for cid
113-
rankedHosts := rendezvous.GlobalHasher.Rank(cid)
114-
first := rankedHosts[0]
115-
rest := rankedHosts[1:3]
114+
ranked := rendezvous.GlobalHasher.Rank(cid)
115+
randIdx := rand.Intn(3)
116+
first, rest := ranked[randIdx], append(ranked[:randIdx], ranked[randIdx+1:]...)[:2]
116117

117118
return &SquareImage{
118119
X150x150: fmt.Sprintf("%s/content/%s/150x150.jpg", first, cid),

api/dbv1/get_tracks.sql.go

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/dbv1/media_link.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package dbv1
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"math/rand"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
"bridgerton.audius.co/config"
13+
"bridgerton.audius.co/rendezvous"
14+
"github.com/ethereum/go-ethereum/common/hexutil"
15+
"github.com/ethereum/go-ethereum/crypto"
16+
)
17+
18+
type MediaLink struct {
19+
Url string `json:"url"`
20+
Mirrors []string `json:"mirrors"`
21+
}
22+
23+
func mediaLink(cid string, trackId int32, userId int32) *MediaLink {
24+
ranked := rendezvous.GlobalHasher.Rank(cid)
25+
randIdx := rand.Intn(3)
26+
first, rest := ranked[randIdx], append(ranked[:randIdx], ranked[randIdx+1:]...)[:2]
27+
28+
timestamp := time.Now().Unix() * 1000
29+
data := map[string]interface{}{
30+
"cid": cid,
31+
"timestamp": timestamp,
32+
"trackId": trackId,
33+
"userId": userId,
34+
}
35+
36+
signature, err := generateSignature(data)
37+
if err != nil {
38+
return nil
39+
}
40+
41+
// Convert the data map to a JSON string
42+
dataJSON, _ := json.Marshal(data)
43+
44+
signatureData := map[string]interface{}{
45+
"signature": signature,
46+
"data": string(dataJSON),
47+
}
48+
signatureJSON, _ := json.Marshal(signatureData)
49+
queryParams := url.Values{}
50+
queryParams.Set("signature", string(signatureJSON))
51+
52+
basePath := fmt.Sprintf("tracks/cidstream/%s", cid)
53+
path := fmt.Sprintf("%s?%s", basePath, queryParams.Encode())
54+
55+
return &MediaLink{
56+
Url: fmt.Sprintf("%s/%s", first, path),
57+
Mirrors: rest,
58+
}
59+
}
60+
61+
func generateSignature(data map[string]interface{}) (string, error) {
62+
ecdsaPrivKey, err := crypto.HexToECDSA(config.Cfg.DelegatePrivateKey)
63+
if err != nil {
64+
return "", err
65+
}
66+
67+
// Sort json
68+
jsonStr := func(data map[string]interface{}) string {
69+
var b bytes.Buffer
70+
_ = json.NewEncoder(&b).Encode(data)
71+
return strings.TrimRight(b.String(), "\n")
72+
}(data)
73+
74+
// Hash the JSON string, prefix it, and hash again
75+
messageHash := crypto.Keccak256([]byte(jsonStr))
76+
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(messageHash))
77+
prefixedMessage := append([]byte(prefix), messageHash...)
78+
finalHash := crypto.Keccak256(prefixedMessage)
79+
80+
// Sign the hash with the private key
81+
signature, err := crypto.Sign(finalHash, ecdsaPrivKey)
82+
if err != nil {
83+
return "", err
84+
}
85+
86+
return hexutil.Encode(signature), nil
87+
}

api/dbv1/media_link_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package dbv1
2+
3+
import (
4+
"testing"
5+
6+
"bridgerton.audius.co/config"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestGenerateSignature(t *testing.T) {
11+
originalCfg := config.Cfg
12+
config.Cfg = config.Config{
13+
DelegatePrivateKey: "0633fddb74e32b3cbc64382e405146319c11a1a52dc96598e557c5dbe2f31468",
14+
}
15+
defer func() {
16+
config.Cfg = originalCfg
17+
}()
18+
19+
data := map[string]interface{}{
20+
"cid": "baeaaaiqsecjitceegveqtb67yhksnwe75w4khfsep5obuuljl2il3wwnm22su",
21+
"timestamp": int64(1744657599000),
22+
"trackId": int32(1462669012),
23+
"userId": int32(0),
24+
}
25+
26+
sig, err := generateSignature(data)
27+
assert.NoError(t, err)
28+
assert.Equal(t, "0xc02c72af125318dcb85eaf8edf6499bec3b17a91e0153b4d89a97cca661746291dde3d06b6b358d77df046eb9e60d65ab1d2a2e4579ae745874186be03957dbc00", sig)
29+
}
30+
31+
func TestGenerateSignatureBadPrivateKey(t *testing.T) {
32+
originalCfg := config.Cfg
33+
config.Cfg = config.Config{
34+
DelegatePrivateKey: "bad-private-key",
35+
}
36+
defer func() {
37+
config.Cfg = originalCfg
38+
}()
39+
40+
data := map[string]interface{}{
41+
"cid": "baeaaaiqsecjitceegveqtb67yhksnwe75w4khfsep5obuuljl2il3wwnm22su",
42+
"timestamp": int64(1744657599000),
43+
"trackId": int32(1462669012),
44+
"userId": int32(0),
45+
}
46+
47+
_, err := generateSignature(data)
48+
assert.Error(t, err)
49+
}

api/dbv1/queries/get_tracks.sql

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
-- name: GetTracks :many
22
SELECT
33
t.track_id,
4-
-- artwork,
54
description,
65
genre,
76
'hashid' as id,
@@ -19,22 +18,17 @@ SELECT
1918
tags,
2019
title,
2120
track_routes.slug as slug,
22-
-- user,
2321
duration,
2422
is_downloadable,
2523
aggregate_plays.count as play_count,
26-
-- permalink,
27-
-- is_streamable,
2824
ddex_app,
2925
-- playlists_containing_track,
3026
pinned_comment_id,
3127
-- album_backlink,
32-
-- access,
3328
t.blocknumber,
3429
create_date,
3530
t.created_at,
3631
cover_art_sizes,
37-
-- cover_art_cids,
3832
credits_splits,
3933
isrc,
4034
license,

api/resolve_middleware.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ func (app *ApiServer) resolveMyIdMiddleware(c *fiber.Ctx) error {
2121
return c.Next()
2222
}
2323

24+
func (app *ApiServer) getMyId(c *fiber.Ctx) int32 {
25+
return int32(c.Locals("myId").(int))
26+
}
27+
2428
func (app *ApiServer) requireUserIdMiddleware(c *fiber.Ctx) error {
2529
userId, err := trashid.DecodeHashId(c.Params("userId"))
2630
if err != nil || userId == 0 {

api/server.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"bridgerton.audius.co/api/dbv1"
12+
"bridgerton.audius.co/config"
1213
"bridgerton.audius.co/trashid"
1314
adapter "github.com/axiomhq/axiom-go/adapters/zap"
1415
"github.com/axiomhq/axiom-go/axiom"
@@ -22,13 +23,7 @@ import (
2223
"go.uber.org/zap/zapcore"
2324
)
2425

25-
type Config struct {
26-
DbUrl string
27-
AxiomToken string
28-
AxiomDataset string
29-
}
30-
31-
func InitLogger(config Config) *zap.Logger {
26+
func InitLogger(config config.Config) *zap.Logger {
3227
// stdout core
3328
encoderConfig := zap.NewProductionEncoderConfig()
3429
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
@@ -68,7 +63,7 @@ func RequestTimer() fiber.Handler {
6863
}
6964
}
7065

71-
func NewApiServer(config Config) *ApiServer {
66+
func NewApiServer(config config.Config) *ApiServer {
7267
logger := InitLogger(config)
7368

7469
pool, err := pgxpool.New(context.Background(), config.DbUrl)
@@ -88,7 +83,10 @@ func NewApiServer(config Config) *ApiServer {
8883
time.Now(),
8984
}
9085

91-
app.Use(recover.New())
86+
app.Use(recover.New(recover.Config{
87+
EnableStackTrace: true,
88+
}))
89+
9290
app.Use(cors.New())
9391
app.Use(RequestTimer())
9492

api/server_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"testing"
1111

12+
"bridgerton.audius.co/config"
1213
"github.com/jackc/pgx/v5"
1314
"github.com/stretchr/testify/assert"
1415
)
@@ -39,7 +40,7 @@ func TestMain(m *testing.M) {
3940
checkErr(err)
4041
}
4142

42-
app = NewApiServer(Config{
43+
app = NewApiServer(config.Config{
4344
DbUrl: "postgres://postgres:example@localhost:21300/test",
4445
})
4546

0 commit comments

Comments
 (0)