Auth: Ensure backwards compatibility for existing API clients #808 #3943

These changes ensure that the new (SHA256) session ID is returned in the
"session_id" field, so that developers have time to update their client
implementations to use the new "access_token" field.

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-07 12:25:56 +01:00
parent 0d2f8be522
commit f8e0615cc8
52 changed files with 422 additions and 370 deletions

View File

@ -47,7 +47,7 @@ const Api = Axios.create({
baseURL: c.apiUri,
headers: {
common: {
"X-Session-ID": window.localStorage.getItem("authToken"),
"X-Auth-Token": window.localStorage.getItem("authToken"),
"X-Client-Uri": c.jsUri,
"X-Client-Version": c.version,
},

View File

@ -28,7 +28,7 @@ import Event from "pubsub-js";
import User from "model/user";
import Socket from "websocket.js";
const RequestHeader = "X-Session-ID";
const RequestHeader = "X-Auth-Token";
const PublicSessionID = "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f";
const PublicAuthToken = "234200000000000000000000000000000000000000000000";
const LoginPage = "login";
@ -209,13 +209,15 @@ export default class Session {
return;
}
if (resp.data.id) {
this.setId(resp.data.id);
if (resp.data.session_id) {
this.setId(resp.data.session_id);
}
if (resp.data.access_token) {
this.setAuthToken(resp.data.access_token);
} else if (resp.data.id) {
// TODO: "id" field is deprecated! Clients should now use "access_token" instead.
// see https://github.com/photoprism/photoprism/commit/0d2f8be522dbf0a051ae6ef78abfc9efded0082d
this.setAuthToken(resp.data.id);
}

View File

@ -133,8 +133,9 @@ Mock.onDelete("api/v1/photos/pqbemz8276mhtobh/label/12345").reply(
Mock.onPost("api/v1/session").reply(
200,
{
id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683",
session_id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683",
access_token: "999900000000000000000000000000000000000000000000",
token_type: "Bearer",
provider: "test",
data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
@ -145,8 +146,9 @@ Mock.onPost("api/v1/session").reply(
Mock.onGet("api/v1/session/a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f").reply(
200,
{
id: "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f",
session_id: "a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f",
access_token: "234200000000000000000000000000000000000000000000",
token_type: "Bearer",
provider: "public",
data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
@ -157,8 +159,9 @@ Mock.onGet("api/v1/session/a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2
Mock.onGet("api/v1/session/5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683").reply(
200,
{
id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683",
session_id: "5aa770f2a1ef431628d9f17bdf82a0d16865e99d4a1ddd9356e1aabfe6464683",
access_token: "999900000000000000000000000000000000000000000000",
token_type: "Bearer",
provider: "test",
data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },

2
go.mod
View File

@ -52,7 +52,7 @@ require (
require (
github.com/go-xmlfmt/xmlfmt v1.1.2 // indirect
golang.org/x/image v0.14.0
golang.org/x/image v0.15.0
)
require github.com/olekukonko/tablewriter v0.0.5

4
go.sum
View File

@ -1297,8 +1297,8 @@ golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmI
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0=
golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

View File

@ -237,7 +237,7 @@ func DeleteAlbum(router *gin.RouterGroup) {
return
}
// PublishAlbumEvent(EntityDeleted, uid, c)
// PublishAlbumEvent(StatusDeleted, uid, c)
UpdateClientConfig()
@ -250,11 +250,10 @@ func DeleteAlbum(router *gin.RouterGroup) {
// LikeAlbum sets the favorite flag for an album.
//
// Request Parameters:
// - uid: string Album UID
//
// POST /api/v1/albums/:uid/like
//
// Parameters:
//
// uid: string Album UID
func LikeAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
@ -287,7 +286,7 @@ func LikeAlbum(router *gin.RouterGroup) {
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, uid, c)
PublishAlbumEvent(StatusUpdated, uid, c)
// Update album YAML backup.
SaveAlbumAsYaml(a)
@ -298,11 +297,10 @@ func LikeAlbum(router *gin.RouterGroup) {
// DislikeAlbum removes the favorite flag from an album.
//
// Request Parameters:
// - uid: string Album UID
//
// DELETE /api/v1/albums/:uid/like
//
// Parameters:
//
// uid: string Album UID
func DislikeAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
@ -335,7 +333,7 @@ func DislikeAlbum(router *gin.RouterGroup) {
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, uid, c)
PublishAlbumEvent(StatusUpdated, uid, c)
// Update album YAML backup.
SaveAlbumAsYaml(a)
@ -402,7 +400,7 @@ func CloneAlbums(router *gin.RouterGroup) {
if len(added) > 0 {
event.SuccessMsg(i18n.MsgSelectionAddedTo, clean.Log(a.Title()))
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
PublishAlbumEvent(StatusUpdated, a.AlbumUID, c)
// Update album YAML backup.
SaveAlbumAsYaml(a)
@ -473,7 +471,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup) {
RemoveFromAlbumCoverCache(a.AlbumUID)
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
PublishAlbumEvent(StatusUpdated, a.AlbumUID, c)
// Update album YAML backup.
SaveAlbumAsYaml(a)
@ -537,7 +535,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) {
RemoveFromAlbumCoverCache(a.AlbumUID)
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
PublishAlbumEvent(StatusUpdated, a.AlbumUID, c)
// Update album YAML backup.
SaveAlbumAsYaml(a)

View File

@ -8,17 +8,23 @@ import (
"github.com/photoprism/photoprism/internal/search"
)
// EntityEvent represents an entity event type.
type EntityEvent string
// Event represents an api event type.
type Event string
const (
EntityUpdated EntityEvent = "updated"
EntityCreated EntityEvent = "created"
EntityDeleted EntityEvent = "deleted"
StatusCreated Event = "created"
StatusUpdated Event = "updated"
StatusDeleted Event = "deleted"
StatusSuccess Event = "success"
)
// String returns the event type as string.
func (ev Event) String() string {
return string(ev)
}
// PublishPhotoEvent publishes updated photo data after changes have been made.
func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishPhotoEvent(ev Event, uid string, c *gin.Context) {
if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, AuthToken(c), string(ev), uid, err)
} else {
@ -27,7 +33,7 @@ func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) {
}
// PublishAlbumEvent publishes updated album data after changes have been made.
func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishAlbumEvent(ev Event, uid string, c *gin.Context) {
f := form.SearchAlbums{UID: uid}
if result, err := search.Albums(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, AuthToken(c), string(ev), uid, err)
@ -37,7 +43,7 @@ func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) {
}
// PublishLabelEvent publishes updated label data after changes have been made.
func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishLabelEvent(ev Event, uid string, c *gin.Context) {
f := form.SearchLabels{UID: uid}
if result, err := search.Labels(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, AuthToken(c), string(ev), uid, err)
@ -47,7 +53,7 @@ func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) {
}
// PublishSubjectEvent publishes updated subject data after changes have been made.
func PublishSubjectEvent(ev EntityEvent, uid string, c *gin.Context) {
func PublishSubjectEvent(ev Event, uid string, c *gin.Context) {
f := form.SearchSubjects{UID: uid}
if result, err := search.Subjects(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, AuthToken(c), string(ev), uid, err)

View File

@ -8,8 +8,9 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
)
// AuthToken returns the client authentication token from the request context,
@ -20,12 +21,14 @@ func AuthToken(c *gin.Context) string {
return ""
}
// First check the X-Session-ID header for an existing ID.
if id := clean.ID(c.GetHeader(header.SessionID)); id != "" {
return id
// First check the "X-Auth-Token" and "X-Session-ID" headers for an auth token.
if token := c.GetHeader(header.AuthToken); token != "" {
return clean.ID(token)
} else if id := c.GetHeader(header.SessionID); id != "" {
return clean.ID(id)
}
// Otherwise, return the bearer token, if any.
// Otherwise, the bearer token from the authorization request header is returned.
return BearerToken(c)
}

View File

@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/header"
)
func TestAuthToken(t *testing.T) {
@ -41,7 +41,7 @@ func TestAuthToken(t *testing.T) {
bearerToken := BearerToken(c)
assert.Equal(t, authToken, bearerToken)
})
t.Run("X-Session-ID", func(t *testing.T) {
t.Run("Header", func(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@ -50,7 +50,7 @@ func TestAuthToken(t *testing.T) {
}
// Add authorization header.
c.Request.Header.Add(header.SessionID, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
c.Request.Header.Add(header.AuthToken, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")
// Check result.
authToken := AuthToken(c)

View File

@ -8,7 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/header"
)
// AddCountHeader adds the actual result count to the response.

View File

@ -14,7 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/header"
)
type CloseableResponseRecorder struct {

View File

@ -23,13 +23,12 @@ const (
// AlbumCover returns an album cover image.
//
// Request Parameters:
// - uid: string album uid
// - token: string security token (see config)
// - size: string thumb type, see photoprism.ThumbnailTypes
//
// GET /api/v1/albums/:uid/t/:token/:size
//
// Parameters:
//
// uid: string album uid
// token: string security token (see config)
// size: string thumb type, see photoprism.ThumbnailTypes
func AlbumCover(router *gin.RouterGroup) {
router.GET("/albums/:uid/t/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {
@ -136,13 +135,12 @@ func AlbumCover(router *gin.RouterGroup) {
// LabelCover returns a label cover image.
//
// Request Parameters:
// - uid: string label uid
// - token: string security token (see config)
// - size: string thumb type, see photoprism.ThumbnailTypes
//
// GET /api/v1/labels/:uid/t/:token/:size
//
// Parameters:
//
// uid: string label uid
// token: string security token (see config)
// size: string thumb type, see photoprism.ThumbnailTypes
func LabelCover(router *gin.RouterGroup) {
router.GET("/labels/:uid/t/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {

View File

@ -35,11 +35,10 @@ func DownloadName(c *gin.Context) customize.DownloadName {
// GetDownload returns the raw file data.
//
// Request Parameters:
// - hash: string The file hash as returned by the files/photos endpoint
//
// GET /api/v1/dl/:hash
//
// Parameters:
//
// hash: string The file hash as returned by the files/photos endpoint
func GetDownload(router *gin.RouterGroup) {
router.GET("/dl/:hash", func(c *gin.Context) {
if InvalidDownloadToken(c) {

View File

@ -16,12 +16,12 @@ import (
)
// DeleteFile removes a file from storage.
//
// Request Parameters:
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
//
// DELETE /api/v1/photos/:uid/files/:file_uid
//
// Parameters:
//
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func DeleteFile(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/files/:file_uid", func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.ActionDelete)
@ -88,7 +88,7 @@ func DeleteFile(router *gin.RouterGroup) {
}
// Notify clients by publishing events.
PublishPhotoEvent(EntityUpdated, photoUid, c)
PublishPhotoEvent(StatusUpdated, photoUid, c)
// Show translated success message.
event.SuccessMsg(i18n.MsgFileDeleted)

View File

@ -4,6 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/get"
@ -14,12 +15,12 @@ import (
)
// ChangeFileOrientation changes the orientation of a file.
//
// Request Parameters:
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
//
// PUT /api/v1/photos/:uid/files/:file_uid/orientation
//
// Parameters:
//
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func ChangeFileOrientation(router *gin.RouterGroup) {
router.PUT("/photos/:uid/files/:file_uid/orientation", func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.ActionUpdate)
@ -99,7 +100,7 @@ func ChangeFileOrientation(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, m.PhotoUID, c)
PublishPhotoEvent(StatusUpdated, m.PhotoUID, c)
c.JSON(http.StatusOK, p)
})

View File

@ -12,9 +12,10 @@ import (
// GetFile returns file details as JSON.
//
// GET /api/v1/files/:hash
// Params:
// Request Parameters:
// - hash (string) SHA-1 hash of the file
//
// GET /api/v1/files/:hash
func GetFile(router *gin.RouterGroup) {
router.GET("/files/:hash", func(c *gin.Context) {
s := Auth(c, acl.ResourceFiles, acl.ActionView)

View File

@ -21,13 +21,12 @@ const (
// FolderCover returns a folder cover image.
//
// Request Parameters:
// - uid: string folder uid
// - token: string url security token, see config
// - size: string thumb type, see thumb.Sizes
//
// GET /api/v1/folders/t/:hash/:token/:size
//
// Parameters:
//
// uid: string folder uid
// token: string url security token, see config
// size: string thumb type, see thumb.Sizes
func FolderCover(router *gin.RouterGroup) {
router.GET("/folders/t/:uid/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {

View File

@ -151,7 +151,7 @@ func StartImport(router *gin.RouterGroup) {
event.Publish("index.completed", eventData)
for _, uid := range f.Albums {
PublishAlbumEvent(EntityUpdated, uid, c)
PublishAlbumEvent(StatusUpdated, uid, c)
}
// Update the user interface.

View File

@ -46,7 +46,7 @@ func UpdateLabel(router *gin.RouterGroup) {
event.SuccessMsg(i18n.MsgLabelSaved)
PublishLabelEvent(EntityUpdated, id, c)
PublishLabelEvent(StatusUpdated, id, c)
c.JSON(http.StatusOK, m)
})
@ -54,11 +54,10 @@ func UpdateLabel(router *gin.RouterGroup) {
// LikeLabel flags a label as favorite.
//
// Request Parameters:
// - uid: string Label UID
//
// POST /api/v1/labels/:uid/like
//
// Parameters:
//
// uid: string Label UID
func LikeLabel(router *gin.RouterGroup) {
router.POST("/labels/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionUpdate)
@ -86,7 +85,7 @@ func LikeLabel(router *gin.RouterGroup) {
})
}
PublishLabelEvent(EntityUpdated, id, c)
PublishLabelEvent(StatusUpdated, id, c)
c.JSON(http.StatusOK, http.Response{})
})
@ -94,11 +93,10 @@ func LikeLabel(router *gin.RouterGroup) {
// DislikeLabel removes the favorite flag from a label.
//
// Request Parameters:
// - uid: string Label UID
//
// DELETE /api/v1/labels/:uid/like
//
// Parameters:
//
// uid: string Label UID
func DislikeLabel(router *gin.RouterGroup) {
router.DELETE("/labels/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionUpdate)
@ -126,7 +124,7 @@ func DislikeLabel(router *gin.RouterGroup) {
})
}
PublishLabelEvent(EntityUpdated, id, c)
PublishLabelEvent(StatusUpdated, id, c)
c.JSON(http.StatusOK, http.Response{})
})

View File

@ -57,7 +57,7 @@ func UpdateLink(c *gin.Context) {
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, link.ShareUID, c)
PublishAlbumEvent(StatusUpdated, link.ShareUID, c)
c.JSON(http.StatusOK, link)
}
@ -82,7 +82,7 @@ func DeleteLink(c *gin.Context) {
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, link.ShareUID, c)
PublishAlbumEvent(StatusUpdated, link.ShareUID, c)
c.JSON(http.StatusOK, link)
}
@ -132,7 +132,7 @@ func CreateLink(c *gin.Context) {
UpdateClientConfig()
PublishAlbumEvent(EntityUpdated, link.ShareUID, c)
PublishAlbumEvent(StatusUpdated, link.ShareUID, c)
c.JSON(http.StatusOK, link)
}

View File

@ -161,7 +161,7 @@ func CreateMarker(router *gin.RouterGroup) {
log.Errorf("faces: %s (update photo title)", err)
} else {
// Publish updated photo entity.
PublishPhotoEvent(EntityUpdated, file.PhotoUID, c)
PublishPhotoEvent(StatusUpdated, file.PhotoUID, c)
}
// Display success message.
@ -174,11 +174,10 @@ func CreateMarker(router *gin.RouterGroup) {
// UpdateMarker updates an existing file area marker to assign faces or other subjects.
//
// Request Parameters:
// - marker_uid: string Marker UID as returned by the API
//
// PUT /api/v1/markers/:marker_uid
//
// Parameters:
//
// marker_uid: string Marker UID as returned by the API
func UpdateMarker(router *gin.RouterGroup) {
router.PUT("/markers/:marker_uid", func(c *gin.Context) {
// Abort if workers runs less than once per hour.
@ -253,7 +252,7 @@ func UpdateMarker(router *gin.RouterGroup) {
log.Errorf("faces: %s (update photo title)", err)
} else {
// Notify clients.
PublishPhotoEvent(EntityUpdated, file.PhotoUID, c)
PublishPhotoEvent(StatusUpdated, file.PhotoUID, c)
}
// Display success message.
@ -266,13 +265,12 @@ func UpdateMarker(router *gin.RouterGroup) {
// ClearMarkerSubject removes an existing marker subject association.
//
// Request Parameters:
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
// - id: int Marker ID as returned by the API
//
// DELETE /api/v1/markers/:marker_uid/subject
//
// Parameters:
//
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
// id: int Marker ID as returned by the API
func ClearMarkerSubject(router *gin.RouterGroup) {
router.DELETE("/markers/:marker_uid/subject", func(c *gin.Context) {
// Abort if workers runs less than once per hour.
@ -314,7 +312,7 @@ func ClearMarkerSubject(router *gin.RouterGroup) {
log.Errorf("faces: %s (update photo title)", err)
} else {
// Notify clients.
PublishPhotoEvent(EntityUpdated, file.PhotoUID, c)
PublishPhotoEvent(StatusUpdated, file.PhotoUID, c)
}
event.SuccessMsg(i18n.MsgChangesSaved)

View File

@ -16,11 +16,12 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// AddPhotoLabel adds a label to a photo.
//
// Request Parameters:
// - uid: string PhotoUID as returned by the API
//
// POST /api/v1/photos/:uid/label
//
// Parameters:
//
// uid: string PhotoUID as returned by the API
func AddPhotoLabel(router *gin.RouterGroup) {
router.POST("/photos/:uid/label", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
@ -82,7 +83,7 @@ func AddPhotoLabel(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, c.Param("uid"), c)
PublishPhotoEvent(StatusUpdated, c.Param("uid"), c)
event.Success("label updated")
@ -90,12 +91,13 @@ func AddPhotoLabel(router *gin.RouterGroup) {
})
}
// RemovePhotoLabel removes a label from a photo.
//
// Request Parameters:
// - uid: string PhotoUID as returned by the API
// - id: int LabelId as returned by the API
//
// DELETE /api/v1/photos/:uid/label/:id
//
// Parameters:
//
// uid: string PhotoUID as returned by the API
// id: int LabelId as returned by the API
func RemovePhotoLabel(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/label/:id", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
@ -146,7 +148,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, clean.UID(c.Param("uid")), c)
PublishPhotoEvent(StatusUpdated, clean.UID(c.Param("uid")), c)
event.Success("label removed")
@ -154,12 +156,13 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
})
}
// UpdatePhotoLabel changes a photo labels.
//
// Request Parameters:
// - uid: string PhotoUID as returned by the API
// - id: int LabelId as returned by the API
//
// PUT /api/v1/photos/:uid/label/:id
//
// Parameters:
//
// uid: string PhotoUID as returned by the API
// id: int LabelId as returned by the API
func UpdatePhotoLabel(router *gin.RouterGroup) {
router.PUT("/photos/:uid/label/:id", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
@ -213,7 +216,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, clean.UID(c.Param("uid")), c)
PublishPhotoEvent(StatusUpdated, clean.UID(c.Param("uid")), c)
event.Success("label saved")

View File

@ -19,12 +19,11 @@ import (
// PhotoUnstack removes a file from an existing photo stack.
//
// Request Parameters:
// - uid: string Photo UID as returned by the API
// - file_uid: string File UID as returned by the API
//
// POST /api/v1/photos/:uid/files/:file_uid/unstack
//
// Parameters:
//
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func PhotoUnstack(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/unstack", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
@ -194,8 +193,8 @@ func PhotoUnstack(router *gin.RouterGroup) {
}
// Notify clients by publishing events.
PublishPhotoEvent(EntityCreated, newPhoto.PhotoUID, c)
PublishPhotoEvent(EntityUpdated, stackPhoto.PhotoUID, c)
PublishPhotoEvent(StatusCreated, newPhoto.PhotoUID, c)
PublishPhotoEvent(StatusUpdated, stackPhoto.PhotoUID, c)
event.SuccessMsg(i18n.MsgFileUnstacked)

View File

@ -38,9 +38,10 @@ func SavePhotoAsYaml(p entity.Photo) {
// GetPhoto returns photo details as JSON.
//
// Route : GET /api/v1/photos/:uid
// Params:
// Request Parameters:
// - uid (string) PhotoUID as returned by the API
//
// GET /api/v1/photos/:uid
func GetPhoto(router *gin.RouterGroup) {
router.GET("/photos/:uid", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionView)
@ -101,7 +102,7 @@ func UpdatePhoto(router *gin.RouterGroup) {
FlushCoverCache()
}
PublishPhotoEvent(EntityUpdated, uid, c)
PublishPhotoEvent(StatusUpdated, uid, c)
event.SuccessMsg(i18n.MsgChangesSaved)
@ -123,7 +124,7 @@ func UpdatePhoto(router *gin.RouterGroup) {
// GetPhotoDownload returns the primary file matching that belongs to the photo.
//
// Route :GET /api/v1/photos/:uid/dl
// Params:
// Request Parameters:
// - uid (string) PhotoUID as returned by the API
func GetPhotoDownload(router *gin.RouterGroup) {
router.GET("/photos/:uid/dl", func(c *gin.Context) {
@ -157,10 +158,10 @@ func GetPhotoDownload(router *gin.RouterGroup) {
// GetPhotoYaml returns photo details as YAML.
//
// GET /api/v1/photos/:uid/yaml
// Params:
// Request Parameters:
// - uid: string PhotoUID as returned by the API
//
// uid: string PhotoUID as returned by the API
// GET /api/v1/photos/:uid/yaml
func GetPhotoYaml(router *gin.RouterGroup) {
router.GET("/photos/:uid/yaml", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.AccessAll)
@ -193,10 +194,10 @@ func GetPhotoYaml(router *gin.RouterGroup) {
// ApprovePhoto marks a photo in review as approved.
//
// POST /api/v1/photos/:uid/approve
// Params:
// Request Parameters:
// - uid: string PhotoUID as returned by the API
//
// uid: string PhotoUID as returned by the API
// POST /api/v1/photos/:uid/approve
func ApprovePhoto(router *gin.RouterGroup) {
router.POST("/photos/:uid/approve", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
@ -221,7 +222,7 @@ func ApprovePhoto(router *gin.RouterGroup) {
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
PublishPhotoEvent(StatusUpdated, id, c)
c.JSON(http.StatusOK, gin.H{"photo": m})
})
@ -229,11 +230,11 @@ func ApprovePhoto(router *gin.RouterGroup) {
// PhotoPrimary sets the primary file for a photo.
//
// POST /photos/:uid/files/:file_uid/primary
// Params:
// Request Parameters:
// - uid: string PhotoUID as returned by the API
// - file_uid: string File UID as returned by the API
//
// uid: string PhotoUID as returned by the API
// file_uid: string File UID as returned by the API
// POST /photos/:uid/files/:file_uid/primary
func PhotoPrimary(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/primary", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
@ -251,7 +252,7 @@ func PhotoPrimary(router *gin.RouterGroup) {
return
}
PublishPhotoEvent(EntityUpdated, uid, c)
PublishPhotoEvent(StatusUpdated, uid, c)
event.SuccessMsg(i18n.MsgChangesSaved)

View File

@ -45,7 +45,7 @@ func LikePhoto(router *gin.RouterGroup) {
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
PublishPhotoEvent(StatusUpdated, id, c)
}
c.JSON(http.StatusOK, gin.H{"photo": m})
@ -85,7 +85,7 @@ func DislikePhoto(router *gin.RouterGroup) {
}
SavePhotoAsYaml(m)
PublishPhotoEvent(EntityUpdated, id, c)
PublishPhotoEvent(StatusUpdated, id, c)
}
c.JSON(http.StatusOK, gin.H{"photo": m})

View File

@ -1,9 +1,6 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/pkg/rnd"
@ -27,39 +24,3 @@ func Session(authToken string) *entity.Session {
return s
}
}
// SessionResponse returns authentication response data based on the session and client config.
func SessionResponse(authToken string, sess *entity.Session, conf config.ClientConfig) gin.H {
if authToken == "" {
return gin.H{
"status": "ok",
"id": sess.ID,
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
}
} else {
return gin.H{
"status": "ok",
"id": sess.ID,
"access_token": authToken,
"token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
}
}
}
// SessionDeleteResponse returns a confirmation response for deleted sessions.
func SessionDeleteResponse(authToken string) gin.H {
if authToken == "" {
return gin.H{"status": "ok"}
} else {
return gin.H{"status": "ok", "id": authToken, "access_token": authToken}
}
}

View File

@ -33,7 +33,7 @@ func CreateSession(router *gin.RouterGroup) {
sess := get.Session().Public()
// Response includes admin account data, session data, and client config values.
response := SessionResponse(sess.AuthToken(), sess, conf.ClientPublic())
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientPublic())
// Return JSON response.
c.JSON(http.StatusOK, response)
@ -80,7 +80,7 @@ func CreateSession(router *gin.RouterGroup) {
AddSessionHeader(c, sess.AuthToken())
// Response includes user data, session data, and client config values.
response := SessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
// Return JSON response.
c.JSON(sess.HttpStatus(), response)

View File

@ -9,7 +9,6 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
@ -26,7 +25,8 @@ func DeleteSession(router *gin.RouterGroup) {
AbortBadRequest(c)
return
} else if get.Config().Public() {
c.JSON(http.StatusOK, gin.H{"status": "running in public mode", "id": session.PublicAuthToken})
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(id))
return
}
@ -58,17 +58,14 @@ func DeleteSession(router *gin.RouterGroup) {
}
}
// Delete session.
// Delete session cache and database record.
if err := get.Session().Delete(id); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s"}, err)
} else {
event.AuditDebug([]string{ClientIP(c), "session deleted"})
}
// Response includes the auth token for confirmation.
response := SessionDeleteResponse(id)
// Return JSON response.
c.JSON(http.StatusOK, response)
// Return JSON response for confirmation.
c.JSON(http.StatusOK, DeleteSessionResponse(id))
})
}

View File

@ -56,7 +56,7 @@ func GetSession(router *gin.RouterGroup) {
AddSessionHeader(c, authToken)
// Response includes user data, session data, and client config values.
response := SessionResponse(authToken, sess, get.Config().ClientSession(sess))
response := GetSessionResponse(authToken, sess, get.Config().ClientSession(sess))
// Return JSON response.
c.JSON(http.StatusOK, response)

View File

@ -0,0 +1,54 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
)
// CreateSessionResponse returns the authentication response data for POST requests
// based on the session and configuration.
func CreateSessionResponse(authToken string, sess *entity.Session, conf config.ClientConfig) gin.H {
return GetSessionResponse(authToken, sess, conf)
}
// GetSessionResponse returns the authentication response data for GET requests
// based on the session and configuration.
func GetSessionResponse(authToken string, sess *entity.Session, conf config.ClientConfig) gin.H {
if authToken == "" {
return gin.H{
"status": StatusSuccess,
"session_id": sess.ID,
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
}
} else {
return gin.H{
"status": StatusSuccess,
// TODO: "id" field is deprecated! Clients should now use "access_token" instead.
// see https://github.com/photoprism/photoprism/commit/0d2f8be522dbf0a051ae6ef78abfc9efded0082d
"id": authToken,
"session_id": sess.ID,
"access_token": authToken,
"token_type": sess.AuthTokenType(),
"expires_in": sess.ExpiresIn(),
"provider": sess.Provider().String(),
"user": sess.User(),
"data": sess.Data(),
"config": conf,
}
}
}
// DeleteSessionResponse returns a confirmation response for DELETE requests.
func DeleteSessionResponse(id string) gin.H {
if id == "" {
return gin.H{"status": StatusDeleted}
} else {
return gin.H{"status": StatusDeleted, "session_id": id}
}
}

View File

@ -28,11 +28,12 @@ func TestSessionResponse(t *testing.T) {
conf := get.Config().ClientSession(sess)
// Create response in public mode.
result := SessionResponse(sess.AuthToken(), sess, conf)
result := GetSessionResponse(sess.AuthToken(), sess, conf)
// Check response.
assert.Equal(t, "ok", result["status"])
assert.Equal(t, sess.ID, result["id"])
assert.Equal(t, StatusSuccess, result["status"])
assert.Equal(t, sess.ID, result["session_id"])
assert.Equal(t, sess.AuthToken(), result["id"])
assert.Equal(t, sess.AuthToken(), result["access_token"])
assert.Equal(t, sess.AuthTokenType(), result["token_type"])
assert.Equal(t, sess.ExpiresIn(), result["expires_in"])
@ -46,11 +47,12 @@ func TestSessionResponse(t *testing.T) {
conf := get.Config().ClientSession(sess)
// Create response without auth token.
result := SessionResponse("", sess, conf)
result := GetSessionResponse("", sess, conf)
// Check response.
assert.Equal(t, "ok", result["status"])
assert.Equal(t, sess.ID, result["id"])
assert.Equal(t, StatusSuccess, result["status"])
assert.Equal(t, sess.ID, result["session_id"])
assert.Nil(t, result["id"])
assert.Nil(t, result["access_token"])
assert.Nil(t, result["token_type"])
assert.Equal(t, sess.ExpiresIn(), result["expires_in"])
@ -70,9 +72,9 @@ func TestCreateSession(t *testing.T) {
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "admin", "password": "photoprism"}`)
log.Debugf("BODY: %s", r.Body.String())
val2 := gjson.Get(r.Body.String(), "user.Name")
assert.Equal(t, "admin", val2.String())
t.Logf("Response Body: %s", r.Body.String())
userName := gjson.Get(r.Body.String(), "user.Name").String()
assert.Equal(t, "admin", userName)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("BadRequest", func(t *testing.T) {
@ -213,6 +215,9 @@ func TestGetSession(t *testing.T) {
t.Logf("Session ID: %s", authToken)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
t.Logf("Response Body: %s", r.Body.String())
id := gjson.Get(r.Body.String(), "session_id").String()
assert.Equal(t, rnd.SessionID(authToken), id)
assert.Equal(t, http.StatusOK, r.Code)
})
}
@ -263,6 +268,9 @@ func TestDeleteSession(t *testing.T) {
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
t.Logf("Response Body: %s", r.Body.String())
id := gjson.Get(r.Body.String(), "session_id").String()
assert.Equal(t, rnd.SessionID(authToken), id)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("AliceSessionAsBob", func(t *testing.T) {

View File

@ -94,11 +94,10 @@ func UpdateSubject(router *gin.RouterGroup) {
// LikeSubject flags a subject as favorite.
//
// Request Parameters:
// - uid: string Subject UID
//
// POST /api/v1/subjects/:uid/like
//
// Parameters:
//
// uid: string Subject UID
func LikeSubject(router *gin.RouterGroup) {
router.POST("/subjects/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourcePeople, acl.ActionUpdate)
@ -120,7 +119,7 @@ func LikeSubject(router *gin.RouterGroup) {
return
}
PublishSubjectEvent(EntityUpdated, uid, c)
PublishSubjectEvent(StatusUpdated, uid, c)
c.JSON(http.StatusOK, http.Response{})
})
@ -128,11 +127,10 @@ func LikeSubject(router *gin.RouterGroup) {
// DislikeSubject removes the favorite flag from a subject.
//
// Request Parameters:
// - uid: string Subject UID
//
// DELETE /api/v1/subjects/:uid/like
//
// Parameters:
//
// uid: string Subject UID
func DislikeSubject(router *gin.RouterGroup) {
router.DELETE("/subjects/:uid/like", func(c *gin.Context) {
s := Auth(c, acl.ResourcePeople, acl.ActionUpdate)
@ -154,7 +152,7 @@ func DislikeSubject(router *gin.RouterGroup) {
return
}
PublishSubjectEvent(EntityUpdated, uid, c)
PublishSubjectEvent(StatusUpdated, uid, c)
c.JSON(http.StatusOK, http.Response{})
})

View File

@ -18,13 +18,12 @@ import (
// GetThumb returns a thumbnail image matching the file hash, crop area, and type.
//
// Request Parameters:
// - thumb: string sha1 file hash plus optional crop area
// - token: string url security token, see config
// - size: string thumb type, see thumb.Sizes
//
// GET /api/v1/t/:thumb/:token/:size
//
// Parameters:
//
// thumb: string sha1 file hash plus optional crop area
// token: string url security token, see config
// size: string thumb type, see thumb.Sizes
func GetThumb(router *gin.RouterGroup) {
router.GET("/t/:thumb/:token/:size", func(c *gin.Context) {
if InvalidPreviewToken(c) {

View File

@ -242,7 +242,7 @@ func ProcessUserUpload(router *gin.RouterGroup) {
event.Publish("upload.completed", event.Data{"uid": opt.UID, "path": uploadPath, "seconds": elapsed})
for _, uid := range f.Albums {
PublishAlbumEvent(EntityUpdated, uid, c)
PublishAlbumEvent(StatusUpdated, uid, c)
}
// Update the user interface.

View File

@ -19,12 +19,11 @@ import (
// GetVideo streams video content.
//
// Request Parameters:
// - hash: string The photo or video file hash as returned by the search API
// - type: string Video format
//
// GET /api/v1/videos/:hash/:token/:type
//
// Parameters:
//
// hash: string The photo or video file hash as returned by the search API
// type: string Video format
func GetVideo(router *gin.RouterGroup) {
router.GET("/videos/:hash/:token/:format", func(c *gin.Context) {
if InvalidPreviewToken(c) {

45
internal/api/websocket.go Normal file
View File

@ -0,0 +1,45 @@
package api
import (
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
)
// wsTimeout specifies the timeout duration for WebSocket connections.
var wsTimeout = 90 * time.Second
// wsSubPerm specifies the permissions required to subscribe to a channel.
var wsSubscribePerms = acl.Permissions{acl.ActionSubscribe}
// wsAuth maps connection IDs to specific users and session IDs.
var wsAuth = struct {
sid map[string]string
rid map[string]string
user map[string]entity.User
mutex sync.RWMutex
}{
sid: make(map[string]string),
rid: make(map[string]string),
user: make(map[string]entity.User),
}
// wsConnection upgrades the HTTP server connection to the WebSocket protocol.
var wsConnection = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// wsClient represents information about the WebSocket client.
type wsClient struct {
AuthToken string `json:"session"`
CssUri string `json:"css"`
JsUri string `json:"js"`
Version string `json:"version"`
}

View File

@ -0,0 +1,58 @@
package api
import (
"sync"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/pkg/rnd"
)
// WebSocket registers the /ws endpoint for establishing websocket connections.
func WebSocket(router *gin.RouterGroup) {
if router == nil {
return
}
conf := get.Config()
if conf == nil {
return
}
router.GET("/ws", func(c *gin.Context) {
w := c.Writer
r := c.Request
ws, err := wsConnection.Upgrade(w, r, nil)
if err != nil {
return
}
var writeMutex sync.Mutex
defer ws.Close()
connId := rnd.UUID()
// Init connection.
wsAuth.mutex.Lock()
if conf.Public() {
wsAuth.user[connId] = entity.Admin
} else {
wsAuth.user[connId] = entity.UnknownUser
}
wsAuth.mutex.Unlock()
// Init writer.
go wsWriter(ws, &writeMutex, connId)
// Init reader.
wsReader(ws, &writeMutex, connId, conf)
})
}

View File

@ -0,0 +1,49 @@
package api
import (
"encoding/json"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
)
// wsReader initializes a WebSocket reader for receiving messages.
func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *config.Config) {
defer ws.Close()
ws.SetReadLimit(4096)
if err := ws.SetReadDeadline(time.Now().Add(wsTimeout)); err != nil {
return
}
ws.SetPongHandler(func(string) error { _ = ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil })
for {
_, m, readErr := ws.ReadMessage()
if readErr != nil {
break
}
var info wsClient
if jsonErr := json.Unmarshal(m, &info); jsonErr != nil {
// Do nothing.
} else {
if s := Session(info.AuthToken); s != nil {
wsAuth.mutex.Lock()
wsAuth.sid[connId] = s.ID
wsAuth.rid[connId] = s.RefID
wsAuth.user[connId] = *s.User()
wsAuth.mutex.Unlock()
wsSendMessage("config.updated", event.Data{"config": conf.ClientSession(s)}, ws, writeMutex)
}
}
}
}

View File

@ -8,14 +8,14 @@ import (
)
func TestWebsocket(t *testing.T) {
t.Run("bad request", func(t *testing.T) {
t.Run("BadRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
WebSocket(router)
r := PerformRequest(app, "GET", "/api/v1/ws")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("router nil", func(t *testing.T) {
t.Run("NoRouter", func(t *testing.T) {
app, _, _ := NewApiTest()
WebSocket(nil)
r := PerformRequest(app, "GET", "/api/v1/ws")

View File

@ -1,8 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"sync"
"time"
@ -11,128 +9,24 @@ import (
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/pkg/rnd"
)
// wsTimeout specifies the timeout duration for WebSocket connections.
var wsTimeout = 90 * time.Second
// wsSubPerm specifies the permissions required to subscribe to a channel.
var wsSubscribePerms = acl.Permissions{acl.ActionSubscribe}
// wsAuth maps connection IDs to specific users and session IDs.
var wsAuth = struct {
sid map[string]string
rid map[string]string
user map[string]entity.User
mutex sync.RWMutex
}{
sid: make(map[string]string),
rid: make(map[string]string),
user: make(map[string]entity.User),
}
// wsConnection upgrades the HTTP server connection to the WebSocket protocol.
var wsConnection = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// wsClient represents information about the WebSocket client.
type wsClient struct {
AuthToken string `json:"session"`
CssUri string `json:"css"`
JsUri string `json:"js"`
Version string `json:"version"`
}
// WebSocket registers the /ws endpoint for establishing websocket connections.
func WebSocket(router *gin.RouterGroup) {
if router == nil {
// wsSendMessage sends a message to the WebSocket client.
func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) {
if topic == "" || ws == nil || writeMutex == nil {
return
}
conf := get.Config()
writeMutex.Lock()
defer writeMutex.Unlock()
if conf == nil {
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
return
}
router.GET("/ws", func(c *gin.Context) {
w := c.Writer
r := c.Request
ws, err := wsConnection.Upgrade(w, r, nil)
if err != nil {
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
return
}
var writeMutex sync.Mutex
defer ws.Close()
connId := rnd.UUID()
// Init connection.
wsAuth.mutex.Lock()
if conf.Public() {
wsAuth.user[connId] = entity.Admin
} else {
wsAuth.user[connId] = entity.UnknownUser
}
wsAuth.mutex.Unlock()
// Init writer.
go wsWriter(ws, &writeMutex, connId)
// Init reader.
wsReader(ws, &writeMutex, connId, conf)
})
}
// wsReader initializes a WebSocket reader for receiving messages.
func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *config.Config) {
defer ws.Close()
ws.SetReadLimit(4096)
if err := ws.SetReadDeadline(time.Now().Add(wsTimeout)); err != nil {
return
}
ws.SetPongHandler(func(string) error { _ = ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil })
for {
_, m, readErr := ws.ReadMessage()
if readErr != nil {
break
}
var info wsClient
if jsonErr := json.Unmarshal(m, &info); jsonErr != nil {
// Do nothing.
} else {
if s := Session(info.AuthToken); s != nil {
wsAuth.mutex.Lock()
wsAuth.sid[connId] = s.ID
wsAuth.rid[connId] = s.RefID
wsAuth.user[connId] = *s.User()
wsAuth.mutex.Unlock()
wsSendMessage("config.updated", event.Data{"config": conf.ClientSession(s)}, ws, writeMutex)
}
}
}
}
// wsWriter initializes a WebSocket writer for sending messages.
@ -228,19 +122,3 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
}
}
}
// wsSendMessage sends a message to the WebSocket client.
func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) {
if topic == "" || ws == nil || writeMutex == nil {
return
}
writeMutex.Lock()
defer writeMutex.Unlock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
return
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
return
}
}

View File

@ -5,9 +5,9 @@ import (
"regexp"
"strings"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/internal/ttl"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/header"
)
const (

View File

@ -10,9 +10,9 @@ import (
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/internal/ttl"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/txt"
)

View File

@ -12,9 +12,9 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/header"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"

View File

@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/header"
"github.com/stretchr/testify/assert"

View File

@ -4,7 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/server/header"
"github.com/photoprism/photoprism/pkg/header"
)
// Security adds common HTTP security headers to the response.

View File

@ -1,4 +0,0 @@
package session
// Header specifies the name of the session HTTP header.
var Header = "X-Session-ID"

View File

@ -2,6 +2,7 @@ package header
const (
SessionID = "X-Session-ID"
AuthToken = "X-Auth-Token"
Authorization = "Authorization" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization
BasicAuth = "Basic"
BearerAuth = "Bearer"