mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -30,10 +30,11 @@ func NewApiRequest(images Files, fileScheme string) (*ApiRequest, error) {
|
|||||||
for i := range images {
|
for i := range images {
|
||||||
switch fileScheme {
|
switch fileScheme {
|
||||||
case scheme.Https:
|
case scheme.Https:
|
||||||
if id, err := download.Register(images[i]); err != nil {
|
fileUuid := rnd.UUID()
|
||||||
|
if err := download.Register(fileUuid, images[i]); err != nil {
|
||||||
return nil, fmt.Errorf("%s (create download url)", err)
|
return nil, fmt.Errorf("%s (create download url)", err)
|
||||||
} else {
|
} else {
|
||||||
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, id)
|
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
|
||||||
}
|
}
|
||||||
case scheme.Data:
|
case scheme.Data:
|
||||||
if file, err := os.Open(images[i]); err != nil {
|
if file, err := os.Open(images[i]); err != nil {
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ func Caption(imgName string, src media.Src) (result CaptionResult, err error) {
|
|||||||
imgUrl = media.DataUrl(file)
|
imgUrl = media.DataUrl(file)
|
||||||
} */
|
} */
|
||||||
|
|
||||||
dlId, dlErr := download.Register(imgName)
|
fileUuid := rnd.UUID()
|
||||||
|
|
||||||
if dlErr != nil {
|
if dlErr := download.Register(imgName, fileUuid); dlErr != nil {
|
||||||
return result, fmt.Errorf("%s (create download url)", err)
|
return result, fmt.Errorf("%s (create download url)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, dlId)
|
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
|
||||||
case media.SrcRemote:
|
case media.SrcRemote:
|
||||||
var u *url.URL
|
var u *url.URL
|
||||||
if u, err = url.Parse(imgName); err != nil {
|
if u, err = url.Parse(imgName); err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/api/download"
|
||||||
"github.com/photoprism/photoprism/internal/event"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ func TestMain(m *testing.M) {
|
|||||||
log = logrus.StandardLogger()
|
log = logrus.StandardLogger()
|
||||||
log.SetLevel(logrus.TraceLevel)
|
log.SetLevel(logrus.TraceLevel)
|
||||||
event.AuditLog = log
|
event.AuditLog = log
|
||||||
|
download.AllowedPaths = append(download.AllowedPaths, AssetsPath)
|
||||||
|
|
||||||
// Set test config values.
|
// Set test config values.
|
||||||
DownloadUrl = "https://app.localssl.dev/api/v1/dl"
|
DownloadUrl = "https://app.localssl.dev/api/v1/dl"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
gc "github.com/patrickmn/go-cache"
|
gc "github.com/patrickmn/go-cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cache = gc.New(time.Minute*15, 5*time.Minute)
|
var expires = time.Minute * 15
|
||||||
|
var cache = gc.New(expires, 5*time.Minute)
|
||||||
|
|
||||||
// Flush resets the download cache.
|
// Flush resets the download cache.
|
||||||
func Flush() {
|
func Flush() {
|
||||||
|
|||||||
24
internal/api/download/download_test.go
Normal file
24
internal/api/download/download_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// Init test logger.
|
||||||
|
log = logrus.StandardLogger()
|
||||||
|
log.SetLevel(logrus.TraceLevel)
|
||||||
|
event.AuditLog = log
|
||||||
|
AllowedPaths = append(AllowedPaths, fs.Abs("./testdata"))
|
||||||
|
|
||||||
|
// Run unit tests.
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
// Find returns the fileName for the given download id or an error if the id is invalid.
|
// Find returns the fileName for the given download id or an error if the id is invalid.
|
||||||
func Find(uniqueId string) (fileName string, err error) {
|
func Find(uniqueId string) (fileName string, err error) {
|
||||||
|
|
||||||
if uniqueId == "" || !rnd.IsUUID(uniqueId) {
|
if uniqueId == "" || !rnd.IsUUID(uniqueId) {
|
||||||
return fileName, fmt.Errorf("id has an invalid format")
|
return fileName, fmt.Errorf("id has an invalid format")
|
||||||
}
|
}
|
||||||
|
|||||||
28
internal/api/download/paths.go
Normal file
28
internal/api/download/paths.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AllowedPaths []string
|
||||||
|
|
||||||
|
// Deny checks if the filename may not be registered for download.
|
||||||
|
func Deny(fileName string) bool {
|
||||||
|
if len(AllowedPaths) == 0 || fileName == "" {
|
||||||
|
return true
|
||||||
|
} else if fileName = fs.Abs(fileName); strings.HasPrefix(fileName, "/etc") ||
|
||||||
|
strings.HasPrefix(filepath.Base(fileName), ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range AllowedPaths {
|
||||||
|
if dir != "" && strings.HasPrefix(fileName, dir+"/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
package download
|
package download
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
"github.com/photoprism/photoprism/pkg/authn"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
"github.com/photoprism/photoprism/pkg/rnd"
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register makes the specified file available for download with the
|
// Register generated an event to make the specified file available
|
||||||
// returned id until the cache expires, or the server is restarted.
|
// for download until the cache expires, or the server is restarted.
|
||||||
func Register(fileName string) (string, error) {
|
func Register(fileUuid, fileName string) error {
|
||||||
if !fs.FileExists(fileName) {
|
if !rnd.IsUUID(fileUuid) {
|
||||||
return "", fmt.Errorf("%s does not exists", clean.Log(fileName))
|
event.AuditWarn([]string{"api", "create download token", "%s", authn.Failed}, fileName)
|
||||||
|
return errors.New("invalid file uuid")
|
||||||
}
|
}
|
||||||
|
|
||||||
uniqueId := rnd.UUID()
|
if fileName = fs.Abs(fileName); !fs.FileExists(fileName) {
|
||||||
cache.SetDefault(uniqueId, fileName)
|
event.AuditWarn([]string{"api", "create download token", "%s", authn.Failed}, fileName)
|
||||||
|
return errors.New("file not found")
|
||||||
|
} else if Deny(fileName) {
|
||||||
|
event.AuditErr([]string{"api", "create download token", "%s", authn.Denied}, fileName)
|
||||||
|
return errors.New("forbidden file path")
|
||||||
|
}
|
||||||
|
|
||||||
return uniqueId, nil
|
event.AuditInfo([]string{"api", "create download token", "%s", authn.Succeeded}, fileName, expires.String())
|
||||||
|
|
||||||
|
cache.SetDefault(fileUuid, fileName)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,27 +11,34 @@ import (
|
|||||||
|
|
||||||
func TestRegister(t *testing.T) {
|
func TestRegister(t *testing.T) {
|
||||||
t.Run("Success", func(t *testing.T) {
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
fileUuid := rnd.UUID()
|
||||||
fileName := fs.Abs("./testdata/image.jpg")
|
fileName := fs.Abs("./testdata/image.jpg")
|
||||||
uniqueId, err := Register(fileName)
|
err := Register(fileUuid, fileName)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.True(t, rnd.IsUUID(uniqueId))
|
assert.True(t, rnd.IsUUID(fileUuid))
|
||||||
|
|
||||||
findName, findErr := Find(uniqueId)
|
findName, findErr := Find(fileUuid)
|
||||||
|
|
||||||
assert.NoError(t, findErr)
|
assert.NoError(t, findErr)
|
||||||
assert.Equal(t, fileName, findName)
|
assert.Equal(t, fileName, findName)
|
||||||
|
|
||||||
Flush()
|
Flush()
|
||||||
|
|
||||||
findName, findErr = Find(uniqueId)
|
findName, findErr = Find(fileUuid)
|
||||||
|
|
||||||
assert.Error(t, findErr)
|
assert.Error(t, findErr)
|
||||||
assert.Equal(t, "", findName)
|
assert.Equal(t, "", findName)
|
||||||
})
|
})
|
||||||
t.Run("NotFound", func(t *testing.T) {
|
t.Run("NotFound", func(t *testing.T) {
|
||||||
|
fileUuid := rnd.UUID()
|
||||||
fileName := fs.Abs("./testdata/invalid.jpg")
|
fileName := fs.Abs("./testdata/invalid.jpg")
|
||||||
uniqueId, err := Register(fileName)
|
err := Register(fileUuid, fileName)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, "", uniqueId)
|
assert.True(t, rnd.IsUUID(fileUuid))
|
||||||
|
|
||||||
|
findName, findErr := Find(fileUuid)
|
||||||
|
|
||||||
|
assert.Error(t, findErr)
|
||||||
|
assert.Equal(t, "", findName)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
31
internal/api/hooks/hooks.go
Normal file
31
internal/api/hooks/hooks.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Package hooks provides webhook authentication and payload handlers.
|
||||||
|
|
||||||
|
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||||
|
<https://docs.photoprism.app/license/agpl>
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||||
|
which describe how our Brand Assets may be used:
|
||||||
|
<https://www.photoprism.app/trademark>
|
||||||
|
|
||||||
|
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||||
|
want to support our work, or just want to say hello.
|
||||||
|
|
||||||
|
Additional information can be found in our Developer Guide:
|
||||||
|
<https://docs.photoprism.app/developer-guide/>
|
||||||
|
*/
|
||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = event.Log
|
||||||
37
internal/api/hooks/payload_test.go
Normal file
37
internal/api/hooks/payload_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPayload_Json(t *testing.T) {
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
timeStamp, timeErr := time.Parse(time.RFC3339Nano, "2025-04-11T11:48:58.540199797Z")
|
||||||
|
|
||||||
|
if timeErr != nil {
|
||||||
|
t.Fatal(timeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := "49b8a329-5aa6-4b76-ba62-bb3adb001817"
|
||||||
|
|
||||||
|
payload := &Payload{
|
||||||
|
Type: "foo.bar",
|
||||||
|
Timestamp: timeStamp.UTC(),
|
||||||
|
Data: event.Data{
|
||||||
|
"id": id,
|
||||||
|
"hello": "World!",
|
||||||
|
"number": 42,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := payload.JSON()
|
||||||
|
expected := `{"type":"foo.bar","timestamp":"2025-04-11T11:48:58.540199797Z","data":{"hello":"World!","id":"49b8a329-5aa6-4b76-ba62-bb3adb001817","number":42}}`
|
||||||
|
|
||||||
|
assert.Equal(t, expected, string(result))
|
||||||
|
})
|
||||||
|
}
|
||||||
27
internal/api/hooks/payloard.go
Normal file
27
internal/api/hooks/payloard.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Payload represents a webhook payload.
|
||||||
|
type Payload struct {
|
||||||
|
Type string `form:"type" json:"type"`
|
||||||
|
Timestamp time.Time `form:"timestamp" json:"timestamp,omitempty"`
|
||||||
|
Data event.Data `form:"data" json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON returns the payload data as JSON-encoded bytes.
|
||||||
|
func (p *Payload) JSON() (b []byte) {
|
||||||
|
b, jsonErr := json.Marshal(p)
|
||||||
|
|
||||||
|
if jsonErr != nil {
|
||||||
|
log.Warningf("hook: %s (json encode)", clean.Error(jsonErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
152
internal/api/hooks/secret.go
Normal file
152
internal/api/hooks/secret.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/media"
|
||||||
|
"github.com/photoprism/photoprism/pkg/media/http/header"
|
||||||
|
)
|
||||||
|
|
||||||
|
var timeTolerance = 5 * time.Minute
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRequiredHeaders = errors.New("missing required headers")
|
||||||
|
ErrInvalidHeaders = errors.New("invalid signature headers")
|
||||||
|
ErrNoMatchingSignature = errors.New("no matching signature found")
|
||||||
|
ErrMessageTooOld = errors.New("message timestamp too old")
|
||||||
|
ErrMessageTooNew = errors.New("message timestamp too new")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Secret struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSecret(secret string) (*Secret, error) {
|
||||||
|
key, err := media.DecodeBase64String(strings.TrimPrefix(secret, header.WebhookSecretPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to create webhook, err: %w", err)
|
||||||
|
}
|
||||||
|
return &Secret{
|
||||||
|
key: key,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebhookRaw(secret []byte) (*Secret, error) {
|
||||||
|
return &Secret{
|
||||||
|
key: secret,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify validates the payload against the webhook signature headers
|
||||||
|
// using the webhooks signing secret.
|
||||||
|
//
|
||||||
|
// Returns an error if the body or headers are missing/unreadable
|
||||||
|
// or if the signature doesn't match.
|
||||||
|
func (wh *Secret) Verify(payload []byte, headers http.Header) error {
|
||||||
|
return wh.verify(payload, headers, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyIgnoringTimestamp validates the payload against the webhook signature headers
|
||||||
|
// using the webhooks signing secret.
|
||||||
|
//
|
||||||
|
// Returns an error if the body or headers are missing/unreadable
|
||||||
|
// or if the signature doesn't match.
|
||||||
|
//
|
||||||
|
// WARNING: This function does not check the signature's timestamp.
|
||||||
|
// We recommend using the `Verify` function instead.
|
||||||
|
func (wh *Secret) VerifyIgnoringTimestamp(payload []byte, headers http.Header) error {
|
||||||
|
return wh.verify(payload, headers, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wh *Secret) verify(payload []byte, headers http.Header, enforceTolerance bool) error {
|
||||||
|
msgId := headers.Get(header.WebhookID)
|
||||||
|
msgSignature := headers.Get(header.WebhookSignature)
|
||||||
|
msgTimestamp := headers.Get(header.WebhookTimestamp)
|
||||||
|
if msgId == "" || msgSignature == "" || msgTimestamp == "" {
|
||||||
|
return fmt.Errorf("unable to verify payload, err: %w", ErrRequiredHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp, err := parseTimestampHeader(msgTimestamp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to verify payload, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if enforceTolerance {
|
||||||
|
if err := verifyTimestamp(timestamp); err != nil {
|
||||||
|
return fmt.Errorf("unable to verify payload, err: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, expectedSignature, err := wh.sign(msgId, timestamp, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to verify payload, err: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passedSignatures := strings.Split(msgSignature, " ")
|
||||||
|
for _, versionedSignature := range passedSignatures {
|
||||||
|
sigParts := strings.Split(versionedSignature, ",")
|
||||||
|
if len(sigParts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
version := sigParts[0]
|
||||||
|
|
||||||
|
if version != "v1" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := []byte(sigParts[1])
|
||||||
|
|
||||||
|
if hmac.Equal(signature, expectedSignature) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("unable to verify payload, err: %w", ErrNoMatchingSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wh *Secret) Sign(msgId string, timestamp time.Time, payload []byte) (string, error) {
|
||||||
|
version, signature, err := wh.sign(msgId, timestamp, payload)
|
||||||
|
return fmt.Sprintf("%s,%s", version, signature), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wh *Secret) sign(msgId string, timestamp time.Time, payload []byte) (version string, signature []byte, err error) {
|
||||||
|
toSign := fmt.Sprintf("%s.%d.%s", msgId, timestamp.Unix(), payload)
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, wh.key)
|
||||||
|
h.Write([]byte(toSign))
|
||||||
|
sig := make([]byte, media.EncodedLenBase64(h.Size()))
|
||||||
|
media.EncodeBase64Bytes(sig, h.Sum(nil))
|
||||||
|
|
||||||
|
return "v1", sig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTimestampHeader(timestampHeader string) (time.Time, error) {
|
||||||
|
timeInt, err := strconv.ParseInt(timestampHeader, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("unable to parse timestamp header, err: %w", errors.Join(err, ErrInvalidHeaders))
|
||||||
|
}
|
||||||
|
timestamp := time.Unix(timeInt, 0)
|
||||||
|
return timestamp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTimestamp(timestamp time.Time) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if now.Sub(timestamp) > timeTolerance {
|
||||||
|
return ErrMessageTooOld
|
||||||
|
}
|
||||||
|
|
||||||
|
if timestamp.After(now.Add(timeTolerance)) {
|
||||||
|
return ErrMessageTooNew
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
260
internal/api/hooks/secret_test.go
Normal file
260
internal/api/hooks/secret_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/media/http/header"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultMsgID = "msg_p5jXN8AQM9LWM0D4loKWxJek"
|
||||||
|
var defaultPayload = []byte(`{"test": 2432232314}`)
|
||||||
|
var defaultSecret = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
|
||||||
|
|
||||||
|
type testPayload struct {
|
||||||
|
id string
|
||||||
|
timestamp time.Time
|
||||||
|
header http.Header
|
||||||
|
secret string
|
||||||
|
payload []byte
|
||||||
|
signature string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestPayload(timestamp time.Time) *testPayload {
|
||||||
|
tp := &testPayload{}
|
||||||
|
tp.id = defaultMsgID
|
||||||
|
tp.timestamp = timestamp
|
||||||
|
|
||||||
|
tp.payload = defaultPayload
|
||||||
|
tp.secret = defaultSecret
|
||||||
|
|
||||||
|
wh, _ := NewSecret(tp.secret)
|
||||||
|
tp.signature, _ = wh.Sign(tp.id, tp.timestamp, tp.payload)
|
||||||
|
|
||||||
|
tp.header = http.Header{}
|
||||||
|
tp.header.Set(header.WebhookID, tp.id)
|
||||||
|
tp.header.Set(header.WebhookSignature, tp.signature)
|
||||||
|
tp.header.Set(header.WebhookTimestamp, fmt.Sprint(tp.timestamp.Unix()))
|
||||||
|
|
||||||
|
return tp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhook(t *testing.T) {
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
testPayload *testPayload
|
||||||
|
modifyPayload func(*testPayload)
|
||||||
|
noEnforceTimestamp bool
|
||||||
|
expectedErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid signature is valid",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing id returns error",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
tp.header.Del("webhook-id")
|
||||||
|
},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing timestamp returns error",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
tp.header.Del("webhook-timestamp")
|
||||||
|
},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing signature returns error",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
tp.header.Del("webhook-signature")
|
||||||
|
},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid signature is invalid",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
tp.header.Set("webhook-signature", "v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=")
|
||||||
|
},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial signature is invalid",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
tp.header.Set("webhook-signature", "v1,")
|
||||||
|
},
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old timestamp fails",
|
||||||
|
testPayload: newTestPayload(time.Now().Add(timeTolerance * -1)),
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new timestamp fails",
|
||||||
|
testPayload: newTestPayload(time.Now().Add(timeTolerance + time.Second)),
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid multi sig is valid",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
sigs := []string{
|
||||||
|
"v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=",
|
||||||
|
"v2,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=",
|
||||||
|
tp.header.Get("webhook-signature"), // valid signature
|
||||||
|
"v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=",
|
||||||
|
}
|
||||||
|
tp.header.Set("webhook-signature", strings.Join(sigs, " "))
|
||||||
|
},
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "old timestamp passes when ignoring tolerance",
|
||||||
|
testPayload: newTestPayload(time.Now().Add(timeTolerance * -1)),
|
||||||
|
noEnforceTimestamp: true,
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new timestamp passes when ignoring tolerance",
|
||||||
|
testPayload: newTestPayload(time.Now().Add(timeTolerance * 1)),
|
||||||
|
noEnforceTimestamp: true,
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid timestamp passes when ignoring tolerance",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
noEnforceTimestamp: true,
|
||||||
|
expectedErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid timestamp fails when ignoring tolerance",
|
||||||
|
testPayload: newTestPayload(time.Now()),
|
||||||
|
modifyPayload: func(tp *testPayload) {
|
||||||
|
tp.header.Set("webhook-timestamp", fmt.Sprint(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).Unix()))
|
||||||
|
},
|
||||||
|
noEnforceTimestamp: true,
|
||||||
|
expectedErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
if tc.modifyPayload != nil {
|
||||||
|
tc.modifyPayload(tc.testPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
wh, err := NewSecret(tc.testPayload.secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tc.noEnforceTimestamp {
|
||||||
|
err = wh.VerifyIgnoringTimestamp(tc.testPayload.payload, tc.testPayload.header)
|
||||||
|
} else {
|
||||||
|
err = wh.Verify(tc.testPayload.payload, tc.testPayload.header)
|
||||||
|
}
|
||||||
|
if err != nil && !tc.expectedErr {
|
||||||
|
t.Errorf("%s: failed with err %s but shouldn't have", tc.name, err.Error())
|
||||||
|
} else if err == nil && tc.expectedErr {
|
||||||
|
t.Errorf("%s: didn't error but should have", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookPrefix(t *testing.T) {
|
||||||
|
tp := newTestPayload(time.Now())
|
||||||
|
|
||||||
|
wh, err := NewSecret(tp.secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = wh.Verify(tp.payload, tp.header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wh, err = NewSecret(fmt.Sprintf("whsec_%s", tp.secret))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = wh.Verify(tp.payload, tp.header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookSign(t *testing.T) {
|
||||||
|
key := "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
|
||||||
|
msgID := "msg_p5jXN8AQM9LWM0D4loKWxJek"
|
||||||
|
timestamp := time.Unix(1614265330, 0)
|
||||||
|
payload := []byte(`{"test": 2432232314}`)
|
||||||
|
expected := "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
|
||||||
|
|
||||||
|
wh, err := NewSecret(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := wh.Sign(msgID, timestamp, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if signature != expected {
|
||||||
|
t.Fatalf("signature %s != expected signature %s", signature, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// A complete example flow for signing and verifying a webhook payload,
|
||||||
|
// including timestamp verification.
|
||||||
|
func TestSignatureFlow(t *testing.T) {
|
||||||
|
const secretKey = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ts = time.Now()
|
||||||
|
id = "1234567890"
|
||||||
|
)
|
||||||
|
|
||||||
|
wh, err := NewSecret(secretKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := `{"type": "example.created", "timestamp":"2023-09-28T19:20:22+00:00", "data":{"str":"string","bool":true,"int":42}}`
|
||||||
|
|
||||||
|
// signing the payload with the webhook handler
|
||||||
|
signature, err := wh.Sign(id, ts, []byte(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generating the http header carrier
|
||||||
|
head := http.Header{}
|
||||||
|
head.Set(header.WebhookID, id)
|
||||||
|
head.Set(header.WebhookSignature, signature)
|
||||||
|
head.Set(header.WebhookTimestamp, fmt.Sprint(ts.Unix()))
|
||||||
|
|
||||||
|
// http request is sent to consumer
|
||||||
|
|
||||||
|
// consumer verifies the signature
|
||||||
|
err = wh.Verify([]byte(payload), head)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done.
|
||||||
|
}
|
||||||
@@ -1602,7 +1602,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/dl/{hash}": {
|
"/api/v1/dl/{file}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/octet-stream"
|
"application/octet-stream"
|
||||||
@@ -1616,8 +1616,8 @@
|
|||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "File Hash",
|
"description": "file hash or unique download id",
|
||||||
"name": "hash",
|
"name": "file",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
}
|
}
|
||||||
@@ -4911,7 +4911,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/vision/faces": {
|
"/api/v1/vision/face": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
@@ -4919,8 +4919,8 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Vision"
|
"Vision"
|
||||||
],
|
],
|
||||||
"summary": "returns the positions and embeddings of detected faces",
|
"summary": "returns the embeddings of a face image",
|
||||||
"operationId": "PostVisionFaces",
|
"operationId": "PostVisionFace",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "list of image file urls",
|
"description": "list of image file urls",
|
||||||
@@ -5018,6 +5018,95 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/vision/nsfw": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Vision"
|
||||||
|
],
|
||||||
|
"summary": "checks the specified images for inappropriate content",
|
||||||
|
"operationId": "PostVisionNsfw",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "list of image file urls",
|
||||||
|
"name": "images",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/vision.ApiRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/vision.ApiResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/i18n.Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/i18n.Response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/i18n.Response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/webhook/{channel}": {
|
||||||
|
"post": {
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Webhook"
|
||||||
|
],
|
||||||
|
"summary": "listens for webhook events and checks their authorization",
|
||||||
|
"operationId": "Webhook",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "webhook event data",
|
||||||
|
"name": "payload",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/hooks.Payload"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/zip": {
|
"/api/v1/zip": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -6568,6 +6657,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"event.Data": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"form.Album": {
|
"form.Album": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7041,6 +7134,20 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
},
|
},
|
||||||
|
"hooks.Payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/event.Data"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"i18n.Response": {
|
"i18n.Response": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7058,6 +7165,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nsfw.Result": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"drawing": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"hentai": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"neutral": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"porn": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sexy": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"search.Album": {
|
"search.Album": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7652,11 +7779,8 @@
|
|||||||
"model": {
|
"model": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"videos": {
|
"url": {
|
||||||
"type": "array",
|
"type": "string"
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7686,10 +7810,16 @@
|
|||||||
"caption": {
|
"caption": {
|
||||||
"$ref": "#/definitions/vision.CaptionResult"
|
"$ref": "#/definitions/vision.CaptionResult"
|
||||||
},
|
},
|
||||||
"faces": {
|
"embeddings": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
@@ -7697,6 +7827,12 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/vision.LabelResult"
|
"$ref": "#/definitions/vision.LabelResult"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"nsfw": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/nsfw.Result"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7706,6 +7842,9 @@
|
|||||||
"confidence": {
|
"confidence": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -7714,8 +7853,11 @@
|
|||||||
"vision.LabelResult": {
|
"vision.LabelResult": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"category": {
|
"categories": {
|
||||||
"type": "string"
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"confidence": {
|
"confidence": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
@@ -7723,6 +7865,12 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"topicality": {
|
"topicality": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
@@ -7737,10 +7885,28 @@
|
|||||||
"resolution": {
|
"resolution": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/definitions/vision.ModelType"
|
||||||
|
},
|
||||||
"version": {
|
"version": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"vision.ModelType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"labels",
|
||||||
|
"nsfw",
|
||||||
|
"face",
|
||||||
|
"caption"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"ModelTypeLabels",
|
||||||
|
"ModelTypeNsfw",
|
||||||
|
"ModelTypeFace",
|
||||||
|
"ModelTypeCaption"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"externalDocs": {
|
"externalDocs": {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
// @Router /api/v1/vision/caption [post]
|
// @Router /api/v1/vision/caption [post]
|
||||||
func PostVisionCaption(router *gin.RouterGroup) {
|
func PostVisionCaption(router *gin.RouterGroup) {
|
||||||
router.POST("/vision/caption", func(c *gin.Context) {
|
router.POST("/vision/caption", func(c *gin.Context) {
|
||||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||||
|
|
||||||
// Abort if permission is not granted.
|
// Abort if permission is not granted.
|
||||||
if s.Abort(c) {
|
if s.Abort(c) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import (
|
|||||||
// @Router /api/v1/vision/face [post]
|
// @Router /api/v1/vision/face [post]
|
||||||
func PostVisionFace(router *gin.RouterGroup) {
|
func PostVisionFace(router *gin.RouterGroup) {
|
||||||
router.POST("/vision/face", func(c *gin.Context) {
|
router.POST("/vision/face", func(c *gin.Context) {
|
||||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||||
|
|
||||||
// Abort if permission is not granted.
|
// Abort if permission is not granted.
|
||||||
if s.Abort(c) {
|
if s.Abort(c) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
// @Router /api/v1/vision/labels [post]
|
// @Router /api/v1/vision/labels [post]
|
||||||
func PostVisionLabels(router *gin.RouterGroup) {
|
func PostVisionLabels(router *gin.RouterGroup) {
|
||||||
router.POST("/vision/labels", func(c *gin.Context) {
|
router.POST("/vision/labels", func(c *gin.Context) {
|
||||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||||
|
|
||||||
// Abort if permission is not granted.
|
// Abort if permission is not granted.
|
||||||
if s.Abort(c) {
|
if s.Abort(c) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
// @Router /api/v1/vision/nsfw [post]
|
// @Router /api/v1/vision/nsfw [post]
|
||||||
func PostVisionNsfw(router *gin.RouterGroup) {
|
func PostVisionNsfw(router *gin.RouterGroup) {
|
||||||
router.POST("/vision/nsfw", func(c *gin.Context) {
|
router.POST("/vision/nsfw", func(c *gin.Context) {
|
||||||
s := Auth(c, acl.ResourceVision, acl.Use)
|
s := Auth(c, acl.ResourceVision, acl.ActionUse)
|
||||||
|
|
||||||
// Abort if permission is not granted.
|
// Abort if permission is not granted.
|
||||||
if s.Abort(c) {
|
if s.Abort(c) {
|
||||||
|
|||||||
120
internal/api/webhook.go
Normal file
120
internal/api/webhook.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/api/download"
|
||||||
|
"github.com/photoprism/photoprism/internal/api/hooks"
|
||||||
|
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||||
|
"github.com/photoprism/photoprism/pkg/authn"
|
||||||
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
|
"github.com/photoprism/photoprism/pkg/i18n"
|
||||||
|
"github.com/photoprism/photoprism/pkg/media/http/header"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Webhook listens for webhook events and checks their authorization.
|
||||||
|
//
|
||||||
|
// @Summary listens for webhook events and checks their authorization
|
||||||
|
// @Id Webhook
|
||||||
|
// @Tags Webhook
|
||||||
|
// @Accept json
|
||||||
|
// @Success 200
|
||||||
|
// @Failure 401,403,429
|
||||||
|
// @Param payload body hooks.Payload true "webhook event data"
|
||||||
|
// @Router /api/v1/webhook/{channel} [post]
|
||||||
|
func Webhook(router *gin.RouterGroup) {
|
||||||
|
requestHandler := func(c *gin.Context) {
|
||||||
|
// Prevent API response caching.
|
||||||
|
c.Header(header.CacheControl, header.CacheControlNoStore)
|
||||||
|
|
||||||
|
// Only the instance channel is currently implemented.
|
||||||
|
if !acl.ChannelInstance.Equal(clean.Token(c.Param("channel"))) {
|
||||||
|
AbortNotImplemented(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For security reasons, this endpoint is not available in public or demo mode.
|
||||||
|
if conf := get.Config(); conf.Public() || conf.Demo() {
|
||||||
|
Abort(c, http.StatusForbidden, i18n.ErrFeatureDisabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s := Auth(c, acl.ResourceWebhooks, acl.ActionPublish)
|
||||||
|
|
||||||
|
if s.Abort(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request hooks.Payload
|
||||||
|
|
||||||
|
// Assign and validate request form values.
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
if err := c.BindQuery(&request); err != nil {
|
||||||
|
event.AuditErr([]string{ClientIP(c), "session %s", "webhook", "%s"}, s.RefID, err)
|
||||||
|
AbortBadRequest(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := c.BindJSON(&request); err != nil {
|
||||||
|
event.AuditErr([]string{ClientIP(c), "session %s", "webhook", "%s"}, s.RefID, err)
|
||||||
|
AbortBadRequest(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventType := clean.TypeLowerUnderscore(request.Type)
|
||||||
|
|
||||||
|
if eventType == "" {
|
||||||
|
event.AuditWarn([]string{ClientIP(c), "session %s", "webhook", "missing type"}, s.RefID)
|
||||||
|
AbortBadRequest(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Data == nil {
|
||||||
|
event.AuditWarn([]string{ClientIP(c), "session %s", "webhook", "missing data"}, s.RefID)
|
||||||
|
AbortBadRequest(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, resourceEv, found := strings.Cut(eventType, ".")
|
||||||
|
|
||||||
|
if !found || resource == "" || resourceEv == "" {
|
||||||
|
event.AuditWarn([]string{ClientIP(c), "session %s", "webhook", "%s", authn.Denied}, s.RefID, eventType)
|
||||||
|
AbortBadRequest(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.IsClient() {
|
||||||
|
if acl.Rules.Deny(acl.Resource(resource), s.ClientRole(), acl.ActionPublish) {
|
||||||
|
event.AuditWarn([]string{ClientIP(c), "session %s", "webhook", "%s", authn.Denied}, s.RefID, eventType)
|
||||||
|
AbortForbidden(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if acl.Rules.Deny(acl.Resource(resource), s.UserRole(), acl.ActionPublish) {
|
||||||
|
event.AuditWarn([]string{ClientIP(c), "session %s", "webhook", "%s", authn.Denied}, s.RefID, eventType)
|
||||||
|
AbortForbidden(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := "instance." + eventType
|
||||||
|
|
||||||
|
switch ev {
|
||||||
|
case "instance.api.downloads.register":
|
||||||
|
_ = download.Register(fmt.Sprintf("%v", request.Data["uuid"]), fmt.Sprintf("%v", request.Data["filename"]))
|
||||||
|
default:
|
||||||
|
event.Publish(ev, request.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
router.GET("/webhook/:channel", requestHandler)
|
||||||
|
router.POST("/webhook/:channel", requestHandler)
|
||||||
|
}
|
||||||
64
internal/api/webhook_test.go
Normal file
64
internal/api/webhook_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/api/hooks"
|
||||||
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
"github.com/photoprism/photoprism/pkg/rnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostWebhook(t *testing.T) {
|
||||||
|
app, router, conf := NewApiTest()
|
||||||
|
conf.SetAuthMode(config.AuthModePasswd)
|
||||||
|
defer conf.SetAuthMode(config.AuthModePublic)
|
||||||
|
Webhook(router)
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
payload := hooks.Payload{
|
||||||
|
Type: "api.downloads.register",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Data: event.Data{
|
||||||
|
"uuid": rnd.UUID(),
|
||||||
|
"filename": fs.Abs("./testdata/cat_224x224.jpg"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body := payload.JSON()
|
||||||
|
token := "9d8b8801ffa23eb52e08ca7766283799ddfd8dd368212123"
|
||||||
|
|
||||||
|
t.Logf("request: %s", string(body))
|
||||||
|
|
||||||
|
response := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/webhook/instance", string(body), token)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, response.Code)
|
||||||
|
})
|
||||||
|
t.Run("InvalidData", func(t *testing.T) {
|
||||||
|
payload := hooks.Payload{
|
||||||
|
Type: "api.downloads.register",
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Data: event.Data{
|
||||||
|
"uuid": 12345,
|
||||||
|
"filename": fs.Abs("./testdata/green_224x224.jpg"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body := payload.JSON()
|
||||||
|
token := "778f0f7d80579a072836c65b786145d6e0127505194cc51e"
|
||||||
|
|
||||||
|
t.Logf("request: %s", string(body))
|
||||||
|
|
||||||
|
response := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/webhook/instance", string(body), token)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, response.Code)
|
||||||
|
})
|
||||||
|
t.Run("Unauthorized", func(t *testing.T) {
|
||||||
|
r := PerformRequest(app, http.MethodPost, "/api/v1/webhook/instance")
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, r.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
AccessPrivate Permission = "access_private"
|
AccessPrivate Permission = "access_private"
|
||||||
AccessOwn Permission = "access_own"
|
AccessOwn Permission = "access_own"
|
||||||
AccessAll Permission = "access_all"
|
AccessAll Permission = "access_all"
|
||||||
Use Permission = "use"
|
ActionUse Permission = "use"
|
||||||
ActionSearch Permission = "search"
|
ActionSearch Permission = "search"
|
||||||
ActionView Permission = "view"
|
ActionView Permission = "view"
|
||||||
ActionUpload Permission = "upload"
|
ActionUpload Permission = "upload"
|
||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
ActionDelete Permission = "delete"
|
ActionDelete Permission = "delete"
|
||||||
ActionRate Permission = "rate"
|
ActionRate Permission = "rate"
|
||||||
ActionReact Permission = "react"
|
ActionReact Permission = "react"
|
||||||
|
ActionPublish Permission = "publish"
|
||||||
ActionSubscribe Permission = "subscribe"
|
ActionSubscribe Permission = "subscribe"
|
||||||
ActionManage Permission = "manage"
|
ActionManage Permission = "manage"
|
||||||
ActionManageOwn Permission = "manage_own"
|
ActionManageOwn Permission = "manage_own"
|
||||||
@@ -58,7 +59,9 @@ const (
|
|||||||
ResourceUsers Resource = "users"
|
ResourceUsers Resource = "users"
|
||||||
ResourceSessions Resource = "sessions"
|
ResourceSessions Resource = "sessions"
|
||||||
ResourceLogs Resource = "logs"
|
ResourceLogs Resource = "logs"
|
||||||
|
ResourceApi Resource = "api"
|
||||||
ResourceWebDAV Resource = "webdav"
|
ResourceWebDAV Resource = "webdav"
|
||||||
|
ResourceWebhooks Resource = "webhooks"
|
||||||
ResourceMetrics Resource = "metrics"
|
ResourceMetrics Resource = "metrics"
|
||||||
ResourceVision Resource = "vision"
|
ResourceVision Resource = "vision"
|
||||||
ResourceFeedback Resource = "feedback"
|
ResourceFeedback Resource = "feedback"
|
||||||
@@ -86,4 +89,5 @@ const (
|
|||||||
ChannelSubjects Resource = "subjects"
|
ChannelSubjects Resource = "subjects"
|
||||||
ChannelPeople Resource = "people"
|
ChannelPeople Resource = "people"
|
||||||
ChannelSync Resource = "sync"
|
ChannelSync Resource = "sync"
|
||||||
|
ChannelInstance Resource = "instance"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ var (
|
|||||||
AccessOwn: true,
|
AccessOwn: true,
|
||||||
AccessShared: true,
|
AccessShared: true,
|
||||||
AccessLibrary: true,
|
AccessLibrary: true,
|
||||||
|
ActionUse: true,
|
||||||
ActionView: true,
|
ActionView: true,
|
||||||
ActionCreate: true,
|
ActionCreate: true,
|
||||||
ActionUpdate: true,
|
ActionUpdate: true,
|
||||||
@@ -20,6 +21,7 @@ var (
|
|||||||
ActionRate: true,
|
ActionRate: true,
|
||||||
ActionReact: true,
|
ActionReact: true,
|
||||||
ActionManage: true,
|
ActionManage: true,
|
||||||
|
ActionPublish: true,
|
||||||
ActionSubscribe: true,
|
ActionSubscribe: true,
|
||||||
}
|
}
|
||||||
GrantUploadAccess = Grant{
|
GrantUploadAccess = Grant{
|
||||||
@@ -42,10 +44,12 @@ var (
|
|||||||
GrantAll = Grant{
|
GrantAll = Grant{
|
||||||
AccessAll: true,
|
AccessAll: true,
|
||||||
AccessOwn: true,
|
AccessOwn: true,
|
||||||
|
ActionUse: true,
|
||||||
ActionView: true,
|
ActionView: true,
|
||||||
ActionCreate: true,
|
ActionCreate: true,
|
||||||
ActionUpdate: true,
|
ActionUpdate: true,
|
||||||
ActionDelete: true,
|
ActionDelete: true,
|
||||||
|
ActionPublish: true,
|
||||||
ActionSubscribe: true,
|
ActionSubscribe: true,
|
||||||
}
|
}
|
||||||
GrantManageOwn = Grant{
|
GrantManageOwn = Grant{
|
||||||
@@ -122,9 +126,13 @@ var (
|
|||||||
AccessAll: true,
|
AccessAll: true,
|
||||||
ActionSubscribe: true,
|
ActionSubscribe: true,
|
||||||
}
|
}
|
||||||
GrantUse = Grant{
|
GrantPublishOwn = Grant{
|
||||||
Use: true,
|
AccessOwn: true,
|
||||||
ActionCreate: true,
|
ActionPublish: true,
|
||||||
|
}
|
||||||
|
GrantUseOwn = Grant{
|
||||||
|
AccessOwn: true,
|
||||||
|
ActionUse: true,
|
||||||
}
|
}
|
||||||
GrantNone = Grant{}
|
GrantNone = Grant{}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ var ResourceNames = []Resource{
|
|||||||
ResourceUsers,
|
ResourceUsers,
|
||||||
ResourceSessions,
|
ResourceSessions,
|
||||||
ResourceLogs,
|
ResourceLogs,
|
||||||
|
ResourceApi,
|
||||||
ResourceWebDAV,
|
ResourceWebDAV,
|
||||||
|
ResourceWebhooks,
|
||||||
ResourceMetrics,
|
ResourceMetrics,
|
||||||
ResourceVision,
|
ResourceVision,
|
||||||
ResourceFeedback,
|
ResourceFeedback,
|
||||||
|
|||||||
@@ -87,17 +87,25 @@ var Rules = ACL{
|
|||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
RoleClient: GrantFullAccess,
|
RoleClient: GrantFullAccess,
|
||||||
},
|
},
|
||||||
|
ResourceApi: Roles{
|
||||||
|
RoleAdmin: GrantFullAccess,
|
||||||
|
RoleClient: GrantPublishOwn,
|
||||||
|
},
|
||||||
ResourceWebDAV: Roles{
|
ResourceWebDAV: Roles{
|
||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
RoleClient: GrantFullAccess,
|
RoleClient: GrantFullAccess,
|
||||||
},
|
},
|
||||||
|
ResourceWebhooks: Roles{
|
||||||
|
RoleAdmin: GrantFullAccess,
|
||||||
|
RoleClient: GrantPublishOwn,
|
||||||
|
},
|
||||||
ResourceMetrics: Roles{
|
ResourceMetrics: Roles{
|
||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
RoleClient: GrantViewAll,
|
RoleClient: GrantViewAll,
|
||||||
},
|
},
|
||||||
ResourceVision: Roles{
|
ResourceVision: Roles{
|
||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
RoleClient: GrantUse,
|
RoleClient: GrantUseOwn,
|
||||||
},
|
},
|
||||||
ResourceFeedback: Roles{
|
ResourceFeedback: Roles{
|
||||||
RoleAdmin: GrantFullAccess,
|
RoleAdmin: GrantFullAccess,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import (
|
|||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/ai/face"
|
"github.com/photoprism/photoprism/internal/ai/face"
|
||||||
"github.com/photoprism/photoprism/internal/ai/vision"
|
"github.com/photoprism/photoprism/internal/ai/vision"
|
||||||
|
"github.com/photoprism/photoprism/internal/api/download"
|
||||||
"github.com/photoprism/photoprism/internal/config/customize"
|
"github.com/photoprism/photoprism/internal/config/customize"
|
||||||
"github.com/photoprism/photoprism/internal/config/ttl"
|
"github.com/photoprism/photoprism/internal/config/ttl"
|
||||||
"github.com/photoprism/photoprism/internal/entity"
|
"github.com/photoprism/photoprism/internal/entity"
|
||||||
@@ -289,6 +290,13 @@ func (c *Config) Propagate() {
|
|||||||
vision.ServiceKey = c.VisionKey()
|
vision.ServiceKey = c.VisionKey()
|
||||||
vision.DownloadUrl = c.DownloadUrl()
|
vision.DownloadUrl = c.DownloadUrl()
|
||||||
|
|
||||||
|
// Set allowed path in download package.
|
||||||
|
download.AllowedPaths = []string{
|
||||||
|
c.SidecarPath(),
|
||||||
|
c.OriginalsPath(),
|
||||||
|
c.ThumbCachePath(),
|
||||||
|
}
|
||||||
|
|
||||||
// Set cache expiration defaults.
|
// Set cache expiration defaults.
|
||||||
ttl.CacheDefault = c.HttpCacheMaxAge()
|
ttl.CacheDefault = c.HttpCacheMaxAge()
|
||||||
ttl.CacheVideo = c.HttpVideoMaxAge()
|
ttl.CacheVideo = c.HttpVideoMaxAge()
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ var SessionFixtures = SessionMap{
|
|||||||
RefID: "sessjr0ge18d",
|
RefID: "sessjr0ge18d",
|
||||||
SessTimeout: 0,
|
SessTimeout: 0,
|
||||||
SessExpires: unix.Now() + unix.Day,
|
SessExpires: unix.Now() + unix.Day,
|
||||||
AuthScope: clean.Scope("metrics photos albums videos"),
|
AuthScope: clean.Scope("metrics photos albums videos api webhooks"),
|
||||||
AuthProvider: authn.ProviderAccessToken.String(),
|
AuthProvider: authn.ProviderAccessToken.String(),
|
||||||
AuthMethod: authn.MethodDefault.String(),
|
AuthMethod: authn.MethodDefault.String(),
|
||||||
GrantType: authn.GrantPassword.String(),
|
GrantType: authn.GrantPassword.String(),
|
||||||
@@ -229,7 +229,7 @@ var SessionFixtures = SessionMap{
|
|||||||
RefID: "sessgh6123yt",
|
RefID: "sessgh6123yt",
|
||||||
SessTimeout: 0,
|
SessTimeout: 0,
|
||||||
SessExpires: unix.Now() + unix.Week,
|
SessExpires: unix.Now() + unix.Week,
|
||||||
AuthScope: clean.Scope("statistics"),
|
AuthScope: clean.Scope("statistics api webhooks"),
|
||||||
AuthProvider: authn.ProviderClient.String(),
|
AuthProvider: authn.ProviderClient.String(),
|
||||||
AuthMethod: authn.MethodOAuth2.String(),
|
AuthMethod: authn.MethodOAuth2.String(),
|
||||||
GrantType: authn.GrantCLI.String(),
|
GrantType: authn.GrantCLI.String(),
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||||||
api.DeleteErrors(APIv1)
|
api.DeleteErrors(APIv1)
|
||||||
api.SendFeedback(APIv1)
|
api.SendFeedback(APIv1)
|
||||||
api.Connect(APIv1)
|
api.Connect(APIv1)
|
||||||
|
api.Webhook(APIv1)
|
||||||
api.WebSocket(APIv1)
|
api.WebSocket(APIv1)
|
||||||
api.GetMetrics(APIv1)
|
api.GetMetrics(APIv1)
|
||||||
api.Echo(APIv1)
|
api.Echo(APIv1)
|
||||||
|
|||||||
Reference in New Issue
Block a user