package core import ( "context" "fmt" "slices" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) type Share interface { Load(ctx context.Context, id string) (*model.Share, error) Repository() model.ShareRepository } func NewShare(ds model.DataStore) Share { return &shareService{ ds: ds, repo: &shareRepositoryWrapper{ShareRepository: ds.Share(), ds: ds}, } } type shareService struct { ds model.DataStore repo *shareRepositoryWrapper } func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) { repo := s.ds.Share() share, err := repo.Get(ctx, id) if err != nil { return nil, err } expiresAt := V(share.ExpiresAt) if !expiresAt.IsZero() && expiresAt.Before(time.Now()) { return nil, model.ErrExpired } share.LastVisitedAt = new(time.Now()) share.VisitCount++ err = repo.Update(ctx, id, *share, "last_visited_at", "visit_count") if err != nil { log.Warn(ctx, "Could not increment visit count for share", "share", share.ID) } return share, nil } func (s *shareService) Repository() model.ShareRepository { return s.repo } type shareRepositoryWrapper struct { model.ShareRepository ds model.DataStore } func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) { for { id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) if err != nil { return "", err } exists, err := r.Exists(ctx, id) if err != nil { return "", err } if !exists { return id, nil } } } func (r *shareRepositoryWrapper) Save(ctx context.Context, s *model.Share) (string, error) { // Owner is always the caller; never trust a client-supplied UserID, as it // determines the library-access context used to resolve the share contents. if user, ok := request.UserFrom(ctx); ok { s.UserID = user.ID } id, err := r.newId(ctx) if err != nil { return "", err } s.ID = id if V(s.ExpiresAt).IsZero() { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } s.ResourceType, err = r.resourceType(ctx, s.ResourceIDs) if err != nil { return "", err } switch s.ResourceType { case "artist": s.Contents = r.contentsLabelFromArtist(ctx, s.ID, s.ResourceIDs) case "album": s.Contents = r.contentsLabelFromAlbums(ctx, s.ID, s.ResourceIDs) case "playlist": s.Contents = r.contentsLabelFromPlaylist(ctx, s.ID, s.ResourceIDs) case "media_file": s.Contents = r.contentsLabelFromMediaFiles(ctx, s.ID, s.ResourceIDs) } s.Contents = str.TruncateRunes(s.Contents, 30, "...") return r.ShareRepository.Save(ctx, s) } var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork} // resourceType resolves every ID as the current user, so an entity they cannot see cannot // ride along behind a valid first one, and requires all IDs to be of the same kind. func (r *shareRepositoryWrapper) resourceType(ctx context.Context, resourceIDs string) (string, error) { resourceType := "" for _, id := range strings.Split(resourceIDs, ",") { kind, err := model.GetEntityKindByID(ctx, r.ds, id) if err != nil { return "", err } if !slices.Contains(shareableKinds, kind) { log.Error(ctx, "Invalid Resource ID", "id", id) return "", model.ErrNotFound } if resourceType != "" && kind.String() != resourceType { return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind) } resourceType = kind.String() } return resourceType, nil } func (r *shareRepositoryWrapper) Update(ctx context.Context, id string, entity model.Share, _ ...string) error { cols := []string{"description", "downloadable"} // TODO Better handling of Share expiration if !V(entity.ExpiresAt).IsZero() { cols = append(cols, "expires_at") } return r.ShareRepository.Update(ctx, id, entity, cols...) } func (r *shareRepositoryWrapper) contentsLabelFromArtist(ctx context.Context, shareID string, ids string) string { idList := strings.SplitN(ids, ",", 2) a, err := r.ds.Artist().Get(ctx, idList[0]) if err != nil { log.Error(ctx, "Error retrieving artist name for share", "share", shareID, err) return "" } return a.Name } func (r *shareRepositoryWrapper) contentsLabelFromAlbums(ctx context.Context, shareID string, ids string) string { idList := strings.Split(ids, ",") all, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) if err != nil { log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) return "" } names := slice.Map(all, func(a model.Album) string { return a.Name }) return strings.Join(names, ", ") } func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(ctx context.Context, shareID string, id string) string { pls, err := r.ds.Playlist().Get(ctx, id) if err != nil { log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) return "" } return pls.Name } func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(ctx context.Context, shareID string, ids string) string { idList := strings.Split(ids, ",") mfs, err := r.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.And{ squirrel.Eq{"media_file.id": idList}, squirrel.Eq{"missing": false}, }}) if err != nil { log.Error(ctx, "Error retrieving media files for share", "share", shareID, err) return "" } if len(mfs) == 1 { return mfs[0].Title } albums := slice.Group(mfs, func(mf model.MediaFile) string { return mf.Album }) if len(albums) == 1 { for name := range albums { return name } } artists := slice.Group(mfs, func(mf model.MediaFile) string { return mf.AlbumArtist }) if len(artists) == 1 { for name := range artists { return name } } return mfs[0].Title }