AI: Add Webhook endpoint and refactor ACL for Vision API #127 #1090

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-04-11 18:41:54 +02:00
parent 35bfe0694b
commit f2ffb0fdce
28 changed files with 1010 additions and 49 deletions

View File

@@ -30,10 +30,11 @@ func NewApiRequest(images Files, fileScheme string) (*ApiRequest, error) {
for i := range images {
switch fileScheme {
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)
} else {
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, id)
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
}
case scheme.Data:
if file, err := os.Open(images[i]); err != nil {

View File

@@ -39,13 +39,13 @@ func Caption(imgName string, src media.Src) (result CaptionResult, err error) {
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)
}
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, dlId)
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
case media.SrcRemote:
var u *url.URL
if u, err = url.Parse(imgName); err != nil {

View File

@@ -6,6 +6,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/api/download"
"github.com/photoprism/photoprism/internal/event"
)
@@ -14,6 +15,7 @@ func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
download.AllowedPaths = append(download.AllowedPaths, AssetsPath)
// Set test config values.
DownloadUrl = "https://app.localssl.dev/api/v1/dl"

View File

@@ -6,7 +6,8 @@ import (
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.
func Flush() {

View 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)
}

View File

@@ -9,7 +9,6 @@ import (
// 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) {
if uniqueId == "" || !rnd.IsUUID(uniqueId) {
return fileName, fmt.Errorf("id has an invalid format")
}

View 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
}

View File

@@ -1,22 +1,33 @@
package download
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/rnd"
)
// Register makes the specified file available for download with the
// returned id until the cache expires, or the server is restarted.
func Register(fileName string) (string, error) {
if !fs.FileExists(fileName) {
return "", fmt.Errorf("%s does not exists", clean.Log(fileName))
// Register generated an event to make the specified file available
// for download until the cache expires, or the server is restarted.
func Register(fileUuid, fileName string) error {
if !rnd.IsUUID(fileUuid) {
event.AuditWarn([]string{"api", "create download token", "%s", authn.Failed}, fileName)
return errors.New("invalid file uuid")
}
uniqueId := rnd.UUID()
cache.SetDefault(uniqueId, fileName)
if fileName = fs.Abs(fileName); !fs.FileExists(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
}

View File

@@ -11,27 +11,34 @@ import (
func TestRegister(t *testing.T) {
t.Run("Success", func(t *testing.T) {
fileUuid := rnd.UUID()
fileName := fs.Abs("./testdata/image.jpg")
uniqueId, err := Register(fileName)
err := Register(fileUuid, fileName)
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.Equal(t, fileName, findName)
Flush()
findName, findErr = Find(uniqueId)
findName, findErr = Find(fileUuid)
assert.Error(t, findErr)
assert.Equal(t, "", findName)
})
t.Run("NotFound", func(t *testing.T) {
fileUuid := rnd.UUID()
fileName := fs.Abs("./testdata/invalid.jpg")
uniqueId, err := Register(fileName)
err := Register(fileUuid, fileName)
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)
})
}

View 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

View 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))
})
}

View 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
}

View 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
}

View 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.
}

View File

@@ -1602,7 +1602,7 @@
}
}
},
"/api/v1/dl/{hash}": {
"/api/v1/dl/{file}": {
"get": {
"produces": [
"application/octet-stream"
@@ -1616,8 +1616,8 @@
"parameters": [
{
"type": "string",
"description": "File Hash",
"name": "hash",
"description": "file hash or unique download id",
"name": "file",
"in": "path",
"required": true
}
@@ -4911,7 +4911,7 @@
}
}
},
"/api/v1/vision/faces": {
"/api/v1/vision/face": {
"post": {
"produces": [
"application/json"
@@ -4919,8 +4919,8 @@
"tags": [
"Vision"
],
"summary": "returns the positions and embeddings of detected faces",
"operationId": "PostVisionFaces",
"summary": "returns the embeddings of a face image",
"operationId": "PostVisionFace",
"parameters": [
{
"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": {
"post": {
"tags": [
@@ -6568,6 +6657,10 @@
}
}
},
"event.Data": {
"type": "object",
"additionalProperties": true
},
"form.Album": {
"type": "object",
"properties": {
@@ -7041,6 +7134,20 @@
"type": "object",
"additionalProperties": {}
},
"hooks.Payload": {
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/event.Data"
},
"timestamp": {
"type": "string"
},
"type": {
"type": "string"
}
}
},
"i18n.Response": {
"type": "object",
"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": {
"type": "object",
"properties": {
@@ -7652,11 +7779,8 @@
"model": {
"type": "string"
},
"videos": {
"type": "array",
"items": {
"type": "string"
}
"url": {
"type": "string"
}
}
},
@@ -7686,10 +7810,16 @@
"caption": {
"$ref": "#/definitions/vision.CaptionResult"
},
"faces": {
"embeddings": {
"type": "array",
"items": {
"type": "string"
"type": "array",
"items": {
"type": "array",
"items": {
"type": "number"
}
}
}
},
"labels": {
@@ -7697,6 +7827,12 @@
"items": {
"$ref": "#/definitions/vision.LabelResult"
}
},
"nsfw": {
"type": "array",
"items": {
"$ref": "#/definitions/nsfw.Result"
}
}
}
},
@@ -7706,6 +7842,9 @@
"confidence": {
"type": "number"
},
"source": {
"type": "string"
},
"text": {
"type": "string"
}
@@ -7714,8 +7853,11 @@
"vision.LabelResult": {
"type": "object",
"properties": {
"category": {
"type": "string"
"categories": {
"type": "array",
"items": {
"type": "string"
}
},
"confidence": {
"type": "number"
@@ -7723,6 +7865,12 @@
"name": {
"type": "string"
},
"priority": {
"type": "integer"
},
"source": {
"type": "string"
},
"topicality": {
"type": "number"
}
@@ -7737,10 +7885,28 @@
"resolution": {
"type": "integer"
},
"type": {
"$ref": "#/definitions/vision.ModelType"
},
"version": {
"type": "string"
}
}
},
"vision.ModelType": {
"type": "string",
"enum": [
"labels",
"nsfw",
"face",
"caption"
],
"x-enum-varnames": [
"ModelTypeLabels",
"ModelTypeNsfw",
"ModelTypeFace",
"ModelTypeCaption"
]
}
},
"externalDocs": {

View File

@@ -23,7 +23,7 @@ import (
// @Router /api/v1/vision/caption [post]
func PostVisionCaption(router *gin.RouterGroup) {
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.
if s.Abort(c) {

View File

@@ -26,7 +26,7 @@ import (
// @Router /api/v1/vision/face [post]
func PostVisionFace(router *gin.RouterGroup) {
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.
if s.Abort(c) {

View File

@@ -25,7 +25,7 @@ import (
// @Router /api/v1/vision/labels [post]
func PostVisionLabels(router *gin.RouterGroup) {
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.
if s.Abort(c) {

View File

@@ -25,7 +25,7 @@ import (
// @Router /api/v1/vision/nsfw [post]
func PostVisionNsfw(router *gin.RouterGroup) {
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.
if s.Abort(c) {

120
internal/api/webhook.go Normal file
View 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)
}

View 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)
})
}

View File

@@ -20,7 +20,7 @@ const (
AccessPrivate Permission = "access_private"
AccessOwn Permission = "access_own"
AccessAll Permission = "access_all"
Use Permission = "use"
ActionUse Permission = "use"
ActionSearch Permission = "search"
ActionView Permission = "view"
ActionUpload Permission = "upload"
@@ -31,6 +31,7 @@ const (
ActionDelete Permission = "delete"
ActionRate Permission = "rate"
ActionReact Permission = "react"
ActionPublish Permission = "publish"
ActionSubscribe Permission = "subscribe"
ActionManage Permission = "manage"
ActionManageOwn Permission = "manage_own"
@@ -58,7 +59,9 @@ const (
ResourceUsers Resource = "users"
ResourceSessions Resource = "sessions"
ResourceLogs Resource = "logs"
ResourceApi Resource = "api"
ResourceWebDAV Resource = "webdav"
ResourceWebhooks Resource = "webhooks"
ResourceMetrics Resource = "metrics"
ResourceVision Resource = "vision"
ResourceFeedback Resource = "feedback"
@@ -86,4 +89,5 @@ const (
ChannelSubjects Resource = "subjects"
ChannelPeople Resource = "people"
ChannelSync Resource = "sync"
ChannelInstance Resource = "instance"
)

View File

@@ -11,6 +11,7 @@ var (
AccessOwn: true,
AccessShared: true,
AccessLibrary: true,
ActionUse: true,
ActionView: true,
ActionCreate: true,
ActionUpdate: true,
@@ -20,6 +21,7 @@ var (
ActionRate: true,
ActionReact: true,
ActionManage: true,
ActionPublish: true,
ActionSubscribe: true,
}
GrantUploadAccess = Grant{
@@ -42,10 +44,12 @@ var (
GrantAll = Grant{
AccessAll: true,
AccessOwn: true,
ActionUse: true,
ActionView: true,
ActionCreate: true,
ActionUpdate: true,
ActionDelete: true,
ActionPublish: true,
ActionSubscribe: true,
}
GrantManageOwn = Grant{
@@ -122,9 +126,13 @@ var (
AccessAll: true,
ActionSubscribe: true,
}
GrantUse = Grant{
Use: true,
ActionCreate: true,
GrantPublishOwn = Grant{
AccessOwn: true,
ActionPublish: true,
}
GrantUseOwn = Grant{
AccessOwn: true,
ActionUse: true,
}
GrantNone = Grant{}
)

View File

@@ -22,7 +22,9 @@ var ResourceNames = []Resource{
ResourceUsers,
ResourceSessions,
ResourceLogs,
ResourceApi,
ResourceWebDAV,
ResourceWebhooks,
ResourceMetrics,
ResourceVision,
ResourceFeedback,

View File

@@ -87,17 +87,25 @@ var Rules = ACL{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceApi: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantPublishOwn,
},
ResourceWebDAV: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantFullAccess,
},
ResourceWebhooks: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantPublishOwn,
},
ResourceMetrics: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantViewAll,
},
ResourceVision: Roles{
RoleAdmin: GrantFullAccess,
RoleClient: GrantUse,
RoleClient: GrantUseOwn,
},
ResourceFeedback: Roles{
RoleAdmin: GrantFullAccess,

View File

@@ -46,6 +46,7 @@ import (
"github.com/photoprism/photoprism/internal/ai/face"
"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/ttl"
"github.com/photoprism/photoprism/internal/entity"
@@ -289,6 +290,13 @@ func (c *Config) Propagate() {
vision.ServiceKey = c.VisionKey()
vision.DownloadUrl = c.DownloadUrl()
// Set allowed path in download package.
download.AllowedPaths = []string{
c.SidecarPath(),
c.OriginalsPath(),
c.ThumbCachePath(),
}
// Set cache expiration defaults.
ttl.CacheDefault = c.HttpCacheMaxAge()
ttl.CacheVideo = c.HttpVideoMaxAge()

View File

@@ -91,7 +91,7 @@ var SessionFixtures = SessionMap{
RefID: "sessjr0ge18d",
SessTimeout: 0,
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(),
AuthMethod: authn.MethodDefault.String(),
GrantType: authn.GrantPassword.String(),
@@ -229,7 +229,7 @@ var SessionFixtures = SessionMap{
RefID: "sessgh6123yt",
SessTimeout: 0,
SessExpires: unix.Now() + unix.Week,
AuthScope: clean.Scope("statistics"),
AuthScope: clean.Scope("statistics api webhooks"),
AuthProvider: authn.ProviderClient.String(),
AuthMethod: authn.MethodOAuth2.String(),
GrantType: authn.GrantCLI.String(),

View File

@@ -197,6 +197,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteErrors(APIv1)
api.SendFeedback(APIv1)
api.Connect(APIv1)
api.Webhook(APIv1)
api.WebSocket(APIv1)
api.GetMetrics(APIv1)
api.Echo(APIv1)