Sharing: Link expiration, view counter and permissions #18

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-06-26 12:16:13 +02:00
parent 3eece7a8ad
commit cfd23666a9
18 changed files with 208 additions and 110 deletions

View file

@ -45,10 +45,18 @@ func GetPhotos(router *gin.RouterGroup) {
return
}
// Guest permissions are limited to shared albums.
if s.Guest() && (f.Album == "" || !s.HasShare(f.Album)){
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
// Guests may only see public content in shared albums.
if s.Guest() {
if f.Album == "" || !s.HasShare(f.Album) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
f.Public = true
f.Private = false
f.Hidden = false
f.Archived = false
f.Review = false
}
result, count, err := query.PhotoSearch(f)

View file

@ -36,7 +36,7 @@ func CreateSession(router *gin.RouterGroup) {
conf := service.Config()
if f.HasToken() {
links := entity.FindLinks(f.Token, "")
links := entity.FindValidLinks(f.Token, "")
if len(links) == 0 {
c.AbortWithStatusJSON(400, gin.H{"error": "Invalid link"})
@ -46,6 +46,7 @@ func CreateSession(router *gin.RouterGroup) {
for _, link := range links {
data.Shares = append(data.Shares, link.ShareUID)
link.Redeem()
}
// Upgrade from anonymous to guest. Don't downgrade.
@ -85,7 +86,7 @@ func CreateSession(router *gin.RouterGroup) {
})
}
// DELETE /api/v1/session/
// DELETE /api/v1/session/:id
func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
id := c.Param("id")

View file

@ -2,7 +2,6 @@ package entity
import (
"database/sql"
"time"
)
type AccountMap map[string]Account
@ -28,13 +27,13 @@ var AccountFixtures = AccountMap{
SyncPath: "/Photos",
SyncStatus: "",
SyncInterval: 3600,
SyncDate: sql.NullTime{Time: time.Now()},
SyncDate: sql.NullTime{Time: Timestamp()},
SyncUpload: true,
SyncDownload: true,
SyncFilenames: true,
SyncRaw: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
},
"webdav-dummy2": {
@ -57,13 +56,13 @@ var AccountFixtures = AccountMap{
SyncPath: "/Photos",
SyncStatus: "test",
SyncInterval: 3600,
SyncDate: sql.NullTime{Time: time.Now()},
SyncDate: sql.NullTime{Time: Timestamp()},
SyncUpload: true,
SyncDownload: true,
SyncFilenames: true,
SyncRaw: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
},
}

View file

@ -97,7 +97,7 @@ func AddPhotoToAlbums(photo string, albums []string) (err error) {
// NewAlbum creates a new album; default name is current month and year
func NewAlbum(albumTitle, albumType string) *Album {
now := time.Now().UTC()
now := Timestamp()
if albumType == "" {
albumType = AlbumDefault
@ -121,7 +121,7 @@ func NewFolderAlbum(albumTitle, albumSlug, albumFilter string) *Album {
return nil
}
now := time.Now().UTC()
now := Timestamp()
result := &Album{
AlbumOrder: SortOrderOldest,
@ -142,7 +142,7 @@ func NewMomentsAlbum(albumTitle, albumSlug, albumFilter string) *Album {
return nil
}
now := time.Now().UTC()
now := Timestamp()
result := &Album{
AlbumOrder: SortOrderOldest,
@ -163,7 +163,7 @@ func NewStateAlbum(albumTitle, albumSlug, albumFilter string) *Album {
return nil
}
now := time.Now().UTC()
now := Timestamp()
result := &Album{
AlbumOrder: SortOrderOldest,
@ -190,7 +190,7 @@ func NewMonthAlbum(albumTitle, albumSlug string, year, month int) *Album {
Public: true,
}
now := time.Now().UTC()
now := Timestamp()
result := &Album{
AlbumOrder: SortOrderOldest,

View file

@ -96,8 +96,8 @@ var CameraFixtures = CameraMap{
CameraType: "",
CameraDescription: "",
CameraNotes: "",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
},
}

View file

@ -52,7 +52,7 @@ func (m *Folder) BeforeCreate(scope *gorm.Scope) error {
// NewFolder creates a new file system directory entity.
func NewFolder(root, pathName string, modTime *time.Time) Folder {
now := time.Now().UTC()
now := Timestamp()
pathName = strings.Trim(pathName, string(os.PathSeparator))

View file

@ -1,9 +1,5 @@
package entity
import (
"time"
)
type LabelMap map[string]Label
func (m LabelMap) Get(name string) Label {
@ -44,8 +40,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -61,8 +57,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 2,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -78,8 +74,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 3,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -95,8 +91,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 4,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -112,8 +108,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 5,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -129,8 +125,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -146,8 +142,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -163,8 +159,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},
@ -180,8 +176,8 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 4,
LabelCategories: []*Label{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
DeletedAt: nil,
New: false,
},

View file

@ -15,12 +15,13 @@ type Link struct {
LinkUID string `gorm:"type:varbinary(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
ShareUID string `gorm:"type:varbinary(42);unique_index:idx_links_uid_token;" json:"ShareUID"`
ShareToken string `gorm:"type:varbinary(255);unique_index:idx_links_uid_token;" json:"ShareToken"`
ShareExpires int `json:"ShareExpires"`
HasPassword bool `json:"HasPassword"`
CanComment bool `json:"CanComment"`
CanEdit bool `json:"CanEdit"`
CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt"`
UpdatedAt time.Time `deepcopier:"skip" json:"UpdatedAt"`
ShareExpires int `json:"ShareExpires" yaml:"ShareExpires,omitempty"`
ShareViews uint `json:"ShareViews" yaml:"-"`
HasPassword bool `json:"HasPassword" yaml:"HasPassword,omitempty"`
CanComment bool `json:"CanComment" yaml:"CanComment,omitempty"`
CanEdit bool `json:"CanEdit" yaml:"CanEdit,omitempty"`
CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt" yaml:"CreatedAt"`
ModifiedAt time.Time `deepcopier:"skip" yaml:"ModifiedAt"`
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
@ -34,17 +35,42 @@ func (m *Link) BeforeCreate(scope *gorm.Scope) error {
// NewLink creates a sharing link.
func NewLink(shareUID string, canComment, canEdit bool) Link {
now := Timestamp()
result := Link{
LinkUID: rnd.PPID('s'),
ShareUID: shareUID,
ShareToken: rnd.Token(10),
CanComment: canComment,
CanEdit: canEdit,
CreatedAt: now,
ModifiedAt: now,
}
return result
}
func (m *Link) Redeem() {
m.ShareViews += 1
result := Db().Model(m).UpdateColumn("ShareViews", m.ShareViews)
if result.RowsAffected == 0 {
log.Warnf("link: failed updating share view counter for %s", m.LinkUID)
}
}
func (m *Link) Expired() bool {
if m.ShareExpires <= 0 {
return false
}
now := Timestamp()
expires := m.ModifiedAt.Add(Seconds(m.ShareExpires))
return now.Before(expires)
}
func (m *Link) SetPassword(password string) error {
pw := NewPassword(m.LinkUID, password)
@ -71,19 +97,6 @@ func (m *Link) InvalidPassword(password string) bool {
return pw.InvalidPassword(password)
}
// Create inserts a new row to the database.
func (m *Link) Create() error {
if !rnd.IsPPID(m.ShareUID, 0) {
return fmt.Errorf("link: invalid share uid (%s)", m.ShareUID)
}
if m.ShareToken == "" {
return fmt.Errorf("link: empty share token")
}
return Db().Create(m).Error
}
// Save inserts a new row to the database or updates a row if the primary key already exists.
func (m *Link) Save() error {
if !rnd.IsPPID(m.ShareUID, 0) {
@ -94,9 +107,12 @@ func (m *Link) Save() error {
return fmt.Errorf("link: empty share token")
}
m.ModifiedAt = Timestamp()
return Db().Save(m).Error
}
// Deletes the link.
func (m *Link) Delete() error {
if m.ShareToken == "" {
return fmt.Errorf("link: empty share token")
@ -142,6 +158,17 @@ func FindLinks(shareToken, shareUID string) (result Links) {
return result
}
// FindValidLinks returns a slice of non-expired links for a token and share UID (at least one must be provided).
func FindValidLinks(shareToken, shareUID string) (result Links) {
for _, link := range FindLinks(shareToken, shareUID) {
if !link.Expired() {
result = append(result, link)
}
}
return result
}
// String returns an human readable identifier for logging.
func (m *Link) String() string {
return fmt.Sprintf("%s/%s", m.ShareUID, m.ShareToken)

View file

@ -14,7 +14,6 @@ var LinkFixtures = LinkMap{
CanComment: true,
CanEdit: false,
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
},
}

View file

@ -1,8 +1,10 @@
package entity
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/stretchr/testify/assert"
)
func TestNewLink(t *testing.T) {
@ -13,3 +15,40 @@ func TestNewLink(t *testing.T) {
assert.Equal(t, 10, len(link.ShareToken))
assert.Equal(t, 16, len(link.LinkUID))
}
func TestLink_Expired(t *testing.T) {
const oneDay = 60 * 60 * 24
link := NewLink("st9lxuqxpogaaba1", true, false)
link.ModifiedAt = Timestamp().Add(-7* Day)
link.ShareExpires = 0
assert.False(t, link.Expired())
link.ShareExpires = oneDay
assert.False(t, link.Expired())
link.ShareExpires = oneDay * 8
assert.True(t, link.Expired())
}
func TestLink_Redeem(t *testing.T) {
link := NewLink(rnd.PPID('a'), false, false)
assert.Equal(t, uint(0), link.ShareViews)
link.Redeem()
assert.Equal(t, uint(1), link.ShareViews)
if err := link.Save(); err != nil {
t.Fatal(err)
}
link.Redeem()
assert.Equal(t, uint(2), link.ShareViews)
}

View file

@ -1,8 +1,6 @@
package entity
import (
"time"
"github.com/photoprism/photoprism/pkg/s2"
)
@ -32,8 +30,8 @@ var LocationFixtures = LocationMap{
LocCategory: "botanical garden",
Place: PlaceFixtures.Pointer("mexico"),
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"caravan park": {
ID: s2.TokenPrefix + "1ef75a71a36c",
@ -44,14 +42,14 @@ var LocationFixtures = LocationMap{
LocCity: "Mandeni",
LocState: "KwaZulu-Natal",
LocCountry: "za",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
LocName: "Lobotes Caravan Park",
LocCategory: "camping",
LocSource: "manual",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"zinkwazi": {
ID: s2.TokenPrefix + "1ef744d1e28c",
@ -60,8 +58,8 @@ var LocationFixtures = LocationMap{
LocName: "Zinkwazi Beach",
LocCategory: "beach",
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"hassloch": {
ID: s2.TokenPrefix + "1ef744d1e280",
@ -70,8 +68,8 @@ var LocationFixtures = LocationMap{
LocName: "Holiday Park",
LocCategory: "park",
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"emptyNameLongCity": {
ID: s2.TokenPrefix + "1ef744d1e281",
@ -80,8 +78,8 @@ var LocationFixtures = LocationMap{
LocName: "",
LocCategory: "botanical garden",
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"emptyNameShortCity": {
ID: s2.TokenPrefix + "1ef744d1e282",
@ -90,8 +88,8 @@ var LocationFixtures = LocationMap{
LocName: "",
LocCategory: "botanical garden",
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"veryLongLocName": {
ID: s2.TokenPrefix + "1ef744d1e283",
@ -100,8 +98,8 @@ var LocationFixtures = LocationMap{
LocName: "longlonglonglonglonglonglonglonglonglonglonglonglongName",
LocCategory: "cape",
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"mediumLongLocName": {
ID: s2.TokenPrefix + "1ef744d1e283",
@ -110,8 +108,8 @@ var LocationFixtures = LocationMap{
LocName: "longlonglonglonglonglongName",
LocCategory: "botanical garden",
LocSource: "places",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
}

View file

@ -143,7 +143,7 @@ func SavePhotoForm(model Photo, form form.Photo, geoApi string) error {
log.Errorf("photo: %s", err.Error())
}
edited := time.Now().UTC()
edited := Timestamp()
model.EditedAt = &edited
model.PhotoQuality = model.QualityScore()
@ -229,7 +229,7 @@ func (m *Photo) ClassifyLabels() classify.Labels {
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Photo) BeforeCreate(scope *gorm.Scope) error {
if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() {
now := time.Now()
now := Timestamp()
if err := scope.SetColumn("TakenAt", now); err != nil {
return err
@ -250,7 +250,7 @@ func (m *Photo) BeforeCreate(scope *gorm.Scope) error {
// BeforeSave ensures the existence of TakenAt properties before indexing or updating a photo
func (m *Photo) BeforeSave(scope *gorm.Scope) error {
if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() {
now := time.Now()
now := Timestamp()
if err := scope.SetColumn("TakenAt", now); err != nil {
return err
@ -854,7 +854,7 @@ func (m *Photo) Approve() error {
return nil
}
edited := time.Now().UTC()
edited := Timestamp()
m.EditedAt = &edited
m.PhotoQuality = m.QualityScore()

View file

@ -98,7 +98,7 @@ func (m *Photo) Maintain() error {
return errors.New("photo: can't maintain, id is empty")
}
checked := time.Now()
checked := Timestamp()
m.CheckedAt = &checked
m.EstimatePlace()

View file

@ -1,8 +1,6 @@
package entity
import (
"time"
"github.com/photoprism/photoprism/pkg/s2"
)
@ -35,8 +33,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "",
LocFavorite: false,
PhotoCount: 1,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"zinkwazi": {
ID: s2.TokenPrefix + "1ef744d1e279",
@ -48,8 +46,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "africa",
LocFavorite: true,
PhotoCount: 2,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"holidaypark": {
ID: s2.TokenPrefix + "1ef744d1e280",
@ -61,8 +59,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "germany",
LocFavorite: true,
PhotoCount: 2,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"emptyNameLongCity": {
ID: s2.TokenPrefix + "1ef744d1e281",
@ -74,8 +72,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "germany",
LocFavorite: true,
PhotoCount: 2,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"emptyNameShortCity": {
ID: s2.TokenPrefix + "1ef744d1e282",
@ -87,8 +85,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "germany",
LocFavorite: true,
PhotoCount: 2,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"veryLongLocName": {
ID: s2.TokenPrefix + "1ef744d1e283",
@ -100,8 +98,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "germany",
LocFavorite: true,
PhotoCount: 2,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
"mediumLongLocName": {
ID: s2.TokenPrefix + "1ef744d1e284",
@ -113,8 +111,8 @@ var PlaceFixtures = PlacesMap{
LocNotes: "",
LocFavorite: true,
PhotoCount: 2,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
},
}

View file

@ -2,7 +2,6 @@ package entity
import (
"testing"
"time"
"github.com/photoprism/photoprism/pkg/s2"
"github.com/stretchr/testify/assert"
@ -67,8 +66,8 @@ func TestPlace_Find(t *testing.T) {
LocNotes: "",
LocFavorite: false,
PhotoCount: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: Timestamp(),
UpdatedAt: Timestamp(),
New: false,
}
err := place.Find()

15
internal/entity/time.go Normal file
View file

@ -0,0 +1,15 @@
package entity
import "time"
const Day = time.Hour * 24
// Timestamp returns the current time in UTC rounded to seconds.
func Timestamp() time.Time {
return time.Now().UTC().Round(time.Second)
}
// Seconds converts an int to a duration in seconds.
func Seconds(s int) time.Duration {
return time.Duration(s) * time.Second
}

View file

@ -0,0 +1,18 @@
package entity
import (
"testing"
"time"
)
func TestTimestamp(t *testing.T) {
result := Timestamp()
if result.Location() != time.UTC {
t.Fatal("timestamp zone must be utc")
}
if result.After(time.Now().Add(time.Second)) {
t.Fatal("timestamp should be in the past from now")
}
}

View file

@ -38,15 +38,15 @@ func New(expiration time.Duration, cachePath string) *Session {
var shared []string
for _, token := range saved.Tokens {
links := entity.FindLinks(token, "")
links := entity.FindValidLinks(token, "")
if len(links) > 0 {
for _, link := range links {
shared = append(shared, link.LinkUID)
}
tokens = append(tokens, token)
}
}
data := Data{User: *user, Tokens: tokens, Shares: shared}
@ -64,6 +64,7 @@ func New(expiration time.Duration, cachePath string) *Session {
return s
}
// Stores all sessions in a JSON file.
func (s *Session) Save() error {
if s.cacheFile == "" {
return nil