API: Improve logs and add /api/v1/connect endpoint for auth callbacks

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2022-07-19 16:58:43 +02:00
parent 3bd7feaf51
commit 0852e659c2
40 changed files with 470 additions and 68 deletions

View File

@@ -33,7 +33,8 @@ import Labels from "pages/labels.vue";
import People from "pages/people.vue";
import Library from "pages/library.vue";
import Settings from "pages/settings.vue";
import AuthLogin from "pages/auth/login.vue";
import Login from "pages/auth/login.vue";
import Connect from "pages/auth/connect.vue";
import Discover from "pages/discover.vue";
import About from "pages/about/about.vue";
import Feedback from "pages/about/feedback.vue";
@@ -78,7 +79,7 @@ export default [
{
name: "login",
path: "/auth/login",
component: AuthLogin,
component: Login,
meta: { title: siteTitle, auth: false, hideNav: true },
beforeEnter: (to, from, next) => {
if (session.isUser()) {
@@ -88,6 +89,17 @@ export default [
}
},
},
{
name: "connect",
path: "/connect/:name/:token",
component: Connect,
meta: {
title: siteTitle,
auth: true,
admin: true,
settings: true,
},
},
{
name: "browse",
path: "/browse",

View File

@@ -349,6 +349,7 @@ ol, ul {
border-radius: 4px;
}
.v-alert,
#photoprism div.v-dialog > div.v-card,
#photoprism div.v-dialog > div.v-card > .v-card__text .v-expansion-panel__container {
border-radius: 6px;

View File

@@ -1,3 +1,23 @@
.p-page-about {
line-height: 1.5rem;
}
.p-page .h30 {
min-height: 30vh;
}
.p-page .h40 {
min-height: 40vh;
}
.p-page .h50 {
min-height: 50vh;
}
.p-page .h60 {
min-height: 60vh;
}
.p-page .h70 {
min-height: 70vh;
}

View File

@@ -0,0 +1,121 @@
<template>
<div class="p-page p-page-connect">
<v-toolbar flat color="secondary" :dense="$vuetify.breakpoint.smAndDown">
<v-toolbar-title>
<a href="https://photoprism.app/membership" target="_blank">Membership</a> <v-icon>navigate_next</v-icon>
<span v-if="busy">
<translate>Busy, please wait</translate>
</span>
<span v-else-if="success">
<translate>Verified</translate>
</span>
<span v-else>
<translate>Connect</translate>
</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-shield" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"></path>
</svg>
</v-toolbar>
<v-container fluid fill-height row wrap class="pa-4">
<v-layout align-center justify-center fill-height>
<v-flex v-if="busy" xs12 d-flex class="text-sm-center py-3">
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
</v-flex>
<v-flex v-else-if="error" xs12 d-flex class="text-sm-left mb-3">
<v-alert
:value="true"
color="error"
icon="gpp_bad"
class="mt-3"
outline
>
{{ error }}
</v-alert>
</v-flex>
<v-flex v-else-if="success" xs12 d-flex class="text-sm-left mb-3">
<v-alert
:value="true"
color="success"
icon="verified_user"
class="mt-3"
outline
>
<translate>Successfully Connected</translate>
</v-alert>
</v-flex>
<v-flex v-else xs12 d-flex class="text-sm-left mb-3">
<v-alert
:value="true"
color="warning"
icon="gpp_maybe"
class="mt-3 ra-4"
outline
>
<translate>Request failed - invalid response</translate>
</v-alert>
</v-flex>
</v-layout>
</v-container>
<p-about-footer></p-about-footer>
</div>
</template>
<script>
import * as options from "options/options";
import Api from "common/api";
export default {
name: 'PPageConnect',
data() {
return {
success: false,
busy: false,
valid: false,
error: "",
options: options,
rtl: this.$rtl,
};
},
created() {
this.$config.load().then(() => {
this.send();
});
},
methods: {
send() {
const name = this.$route.params.name.replace(/[^a-z0-9]/gi, '');
const values = {Token: this.$route.params.token};
if (name !== '' && values.Token.length >= 4) {
this.busy = true;
this.$notify.blockUI();
Api.put("connect/"+name, values).then(() => {
this.$notify.success(this.$gettext("Connected"));
this.success = true;
this.busy = false;
this.$config.update();
}).catch((error) => {
this.busy = false;
if (error.response && error.response.data) {
let data = error.response.data;
this.error = data.message ? data.message : data.error;
}
if(!this.error) {
this.error = this.$gettext("Invalid parameters");
}
}).finally(() => {
this.$notify.unblockUI();
});
} else {
this.$notify.error(this.$gettext("Invalid parameters"));
this.$router.push({name: "settings"});
}
},
},
};
</script>

View File

@@ -97,7 +97,7 @@
<script>
export default {
name: "PPageAuthLogin",
name: "PPageLogin",
data() {
const c = this.$config.values;
const sponsor = this.$config.isSponsor();

View File

@@ -17,7 +17,6 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/workers"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -89,7 +88,7 @@ func GetAccountFolders(router *gin.RouterGroup) {
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(fs.FileInfos)
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return

View File

@@ -28,6 +28,7 @@ package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -60,7 +61,7 @@ func UpdateClientConfig() {
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
log.Debugf("api: abort %s with code %d (%s)", clean.Log(c.FullPath()), code, resp.String())
log.Debugf("api-v1: abort %s with code %d (%s)", clean.Log(c.FullPath()), code, strings.ToLower(resp.String()))
c.AbortWithStatusJSON(code, resp)
}
@@ -70,7 +71,7 @@ func Error(c *gin.Context, code int, err error, id i18n.Message, params ...inter
if err != nil {
resp.Details = err.Error()
log.Errorf("api: error %s with code %d in %s (%s)", clean.Log(err.Error()), code, clean.Log(c.FullPath()), resp.String())
log.Errorf("api-v1: error %s with code %d in %s (%s)", clean.Log(err.Error()), code, clean.Log(c.FullPath()), strings.ToLower(resp.String()))
}
c.AbortWithStatusJSON(code, resp)

74
internal/api/connect.go Normal file
View File

@@ -0,0 +1,74 @@
package api
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/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
// Connect confirms external service accounts using a token.
//
// PUT /api/v1/connect/:name
func Connect(router *gin.RouterGroup) {
router.PUT("/connect/:name", func(c *gin.Context) {
name := clean.IdString(c.Param("name"))
if name == "" {
log.Errorf("connect: empty service name")
AbortBadRequest(c)
return
}
var f form.Connect
if err := c.BindJSON(&f); err != nil {
log.Warnf("connect: invalid form values (%s)", clean.Log(name))
Abort(c, http.StatusBadRequest, i18n.ErrAccountConnect)
return
}
if f.Invalid() {
log.Warnf("connect: invalid token %s", clean.Log(f.Token))
Abort(c, http.StatusBadRequest, i18n.ErrAccountConnect)
return
}
conf := service.Config()
if conf.Public() {
Abort(c, http.StatusForbidden, i18n.ErrPublic)
return
}
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionUpdate)
if s.Invalid() {
log.Errorf("connect: %s not authorized", clean.Log(s.User.UserName))
AbortUnauthorized(c)
return
}
var err error
switch name {
case "hub":
err = conf.ResyncHub(f.Token)
default:
log.Errorf("connect: invalid service %s", clean.Log(name))
Abort(c, http.StatusBadRequest, i18n.ErrAccountConnect)
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} else {
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK})
}
})
}

View File

@@ -0,0 +1,46 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConnect(t *testing.T) {
t.Run("NoNameOrToken", func(t *testing.T) {
app, router, _ := NewApiTest()
Connect(router)
r := PerformRequest(app, "PUT", "/api/v1/connect")
assert.Equal(t, 404, r.Code)
})
t.Run("NoName", func(t *testing.T) {
app, router, _ := NewApiTest()
Connect(router)
r := PerformRequest(app, "PUT", "/api/v1/connect/")
assert.Equal(t, 404, r.Code)
})
t.Run("NoToken", func(t *testing.T) {
app, router, _ := NewApiTest()
Connect(router)
r := PerformRequest(app, "PUT", "/api/v1/connect/hub/")
assert.Equal(t, 307, r.Code)
})
t.Run("InvalidUrl", func(t *testing.T) {
app, router, _ := NewApiTest()
Connect(router)
r := PerformRequest(app, "PUT", "/api/v1/connect/hub/a")
assert.Equal(t, 404, r.Code)
})
t.Run("Redirect", func(t *testing.T) {
app, router, _ := NewApiTest()
Connect(router)
r := PerformRequest(app, "PUT", "/api/v1/connect/hub/foobar123")
assert.NotEqual(t, 301, r.Code)
})
t.Run("HasNameAndToken", func(t *testing.T) {
app, router, _ := NewApiTest()
Connect(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/connect/hub/", `{"Token": "foobar123"}`)
assert.NotEqual(t, 200, r.Code)
})
}

View File

@@ -5,13 +5,13 @@ import (
"path/filepath"
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -53,7 +53,7 @@ func AlbumCover(router *gin.RouterGroup) {
cacheKey := CacheKey(albumCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)
@@ -165,7 +165,7 @@ func LabelCover(router *gin.RouterGroup) {
cacheKey := CacheKey(labelCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View File

@@ -3,13 +3,12 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/service"
)
// SendFeedback sends a feedback message.

View File

@@ -5,13 +5,13 @@ import (
"path/filepath"
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -62,7 +62,7 @@ func FolderCover(router *gin.RouterGroup) {
cacheKey := CacheKey(folderCover, uid, string(thumbName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View File

@@ -76,7 +76,7 @@ func SearchFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string)
if cacheData, ok := cache.Get(cacheKey); ok {
cached := cacheData.(FoldersResponse)
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
c.JSON(http.StatusOK, cached)
return

View File

@@ -12,7 +12,6 @@ import (
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -97,7 +96,7 @@ func GetThumb(router *gin.RouterGroup) {
cacheKey := CacheKey("thumbs", fileHash, string(sizeName))
if cacheData, ok := cache.Get(cacheKey); ok {
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
log.Tracef("api-v1: cache hit for %s [%s]", cacheKey, time.Since(start))
cached := cacheData.(ThumbCache)

View File

@@ -18,10 +18,10 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// FacesCommand registers the facial recognition subcommands.
// FacesCommand registers the face recognition subcommands.
var FacesCommand = cli.Command{
Name: "faces",
Usage: "Facial recognition subcommands",
Usage: "Face recognition subcommands",
Subcommands: []cli.Command{
{
Name: "stats",

View File

@@ -27,6 +27,7 @@ import (
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/hub"
"github.com/photoprism/photoprism/internal/hub/places"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/clean"
@@ -151,7 +152,7 @@ func (c *Config) Propagate() {
places.UserAgent = c.UserAgent()
entity.GeoApi = c.GeoApi()
// Set facial recognition parameters.
// Set face recognition parameters.
face.ScoreThreshold = c.FaceScore()
face.OverlapThreshold = c.FaceOverlap()
face.ClusterScoreThreshold = c.FaceClusterScore()
@@ -672,15 +673,25 @@ func (c *Config) ResolutionLimit() int {
return result
}
// UpdateHub updates backend api credentials for maps & places.
// UpdateHub renews backend api credentials for maps & places without a token.
func (c *Config) UpdateHub() {
if err := c.hub.Refresh(); err != nil {
log.Debugf("config: %s", err)
} else if err := c.hub.Save(); err != nil {
log.Debugf("config: %s", err)
_ = c.ResyncHub("")
}
// ResyncHub renews backend api credentials for maps & places with an optional token.
func (c *Config) ResyncHub(token string) error {
if err := c.hub.Resync(token); err != nil {
log.Debugf("config: %s (refresh backend api tokens)", err)
if token != "" {
return i18n.Error(i18n.ErrAccountConnect)
}
} else if err = c.hub.Save(); err != nil {
log.Debugf("config: %s (save backend api tokens)", err)
} else {
c.hub.Propagate()
}
return nil
}
// initHub initializes PhotoPrism hub config.
@@ -693,10 +704,10 @@ func (c *Config) initHub() {
if err := c.hub.Load(); err == nil {
// Do nothing.
} else if err := c.hub.Refresh(); err != nil {
log.Debugf("config: %s", err)
} else if err := c.hub.Update(); err != nil {
log.Debugf("config: %s (init backend api tokens)", err)
} else if err := c.hub.Save(); err != nil {
log.Debugf("config: %s", err)
log.Debugf("config: %s (save backend api tokens)", err)
}
c.hub.Propagate()

View File

@@ -10,7 +10,7 @@ import (
const (
AuthModePublic = "public"
AuthModePasswd = "passwd"
AuthModePasswd = "password"
)
func isBcrypt(s string) bool {
@@ -53,7 +53,7 @@ func (c *Config) AuthMode() string {
switch mode {
case AuthModePublic:
return AuthModePublic
case "", "pw", "pass", "passwd", "password", "passwort", "passwords":
case AuthModePasswd, "", "p", "pw", "pass", "passwd", "passwort", "passwords":
return AuthModePasswd
default:
return AuthModePasswd

View File

@@ -50,7 +50,7 @@ func (c *Config) DisableTensorFlow() bool {
return c.options.DisableTensorFlow
}
// DisableFaces checks if facial recognition is disabled.
// DisableFaces checks if face recognition is disabled.
func (c *Config) DisableFaces() bool {
if c.DisableTensorFlow() || c.options.DisableFaces {
return true

View File

@@ -238,7 +238,7 @@ var Flags = CliFlags{
CliFlag{
Flag: cli.BoolFlag{
Name: "disable-faces",
Usage: "disable facial recognition",
Usage: "disable face recognition",
EnvVar: "PHOTOPRISM_DISABLE_FACES",
}},
CliFlag{

View File

@@ -1,6 +1,6 @@
/*
Package face provides facial recognition.
Package face provides face recognition.
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.

15
internal/form/connect.go Normal file
View File

@@ -0,0 +1,15 @@
package form
import (
"github.com/photoprism/photoprism/pkg/rnd"
)
// Connect represents a connection request with an external service.
type Connect struct {
Token string `json:"Token"`
}
// Invalid tests if the form data is invalid.
func (f Connect) Invalid() bool {
return !rnd.ValidateCrcToken(f.Token)
}

View File

@@ -0,0 +1,22 @@
package form
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConnect_Invalid(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
f := Connect{}
assert.True(t, f.Invalid())
})
t.Run("Invalid", func(t *testing.T) {
f := Connect{Token: "1mna-2a4t-8729"}
assert.True(t, f.Invalid())
})
t.Run("Valid", func(t *testing.T) {
f := Connect{Token: "q85v-196o-7eb4"}
assert.False(t, f.Invalid())
})
}

View File

@@ -8,18 +8,18 @@ import (
func TestFeedback_Empty(t *testing.T) {
t.Run("true", func(t *testing.T) {
feedback := Feedback{}
assert.True(t, feedback.Empty())
f := Feedback{}
assert.True(t, f.Empty())
})
t.Run("false", func(t *testing.T) {
feedback := Feedback{Message: "I found a bug", Category: "Bug Report", UserEmail: "test@test.com"}
assert.False(t, feedback.Empty())
f := Feedback{Message: "I found a bug", Category: "Bug Report", UserEmail: "test@test.com"}
assert.False(t, f.Empty())
})
t.Run("false", func(t *testing.T) {
feedback, err := NewFeedback("")
if err != nil {
if f, err := NewFeedback(""); err != nil {
t.Fatal(err)
} else {
assert.True(t, f.Empty())
}
assert.True(t, feedback.Empty())
})
}

View File

@@ -143,8 +143,13 @@ func (c *Config) DecodeSession() (Session, error) {
return result, nil
}
// Refresh updates backend api credentials.
func (c *Config) Refresh() (err error) {
// Update renews backend api credentials without a token.
func (c *Config) Update() error {
return c.Resync("")
}
// Resync renews backend api credentials with an optional token.
func (c *Config) Resync(token string) (err error) {
mutex.Lock()
defer mutex.Unlock()
@@ -176,8 +181,10 @@ func (c *Config) Refresh() (err error) {
log.Debugf("config: requesting new api key for maps and places")
}
// Create request.
if j, err := json.Marshal(NewRequest(c.Version, c.Serial, c.Env, c.PartnerID)); err != nil {
// Create JSON request.
var j []byte
if j, err = json.Marshal(NewRequest(c.Version, c.Serial, c.Env, c.PartnerID, token)); err != nil {
return err
} else if req, err = http.NewRequest(method, url, bytes.NewReader(j)); err != nil {
return err

View File

@@ -55,7 +55,7 @@ func TestNewConfig(t *testing.T) {
}
func TestNewRequest(t *testing.T) {
r := NewRequest("0.0.0", "zqkunt22r0bewti9", "test", "test")
r := NewRequest("0.0.0", "zqkunt22r0bewti9", "test", "test", "")
assert.IsType(t, &Request{}, r)
@@ -74,7 +74,7 @@ func TestConfig_Refresh(t *testing.T) {
c := NewConfig("0.0.0", fileName, "zqkunt22r0bewti9", "test", "PhotoPrism/Test", "test")
if err := c.Refresh(); err != nil {
if err := c.Update(); err != nil {
t.Fatal(err)
}
@@ -96,7 +96,7 @@ func TestConfig_Refresh(t *testing.T) {
assert.FileExists(t, fileName)
if err := c.Refresh(); err != nil {
if err := c.Update(); err != nil {
t.Fatal(err)
}

View File

@@ -16,10 +16,11 @@ type Request struct {
ClientCPU int `json:"ClientCPU"`
ClientEnv string `json:"ClientEnv"`
PartnerID string `json:"PartnerID"`
ApiToken string `json:"ApiToken"`
}
// NewRequest creates a new backend key request instance.
func NewRequest(version, serial, env, partnerId string) *Request {
func NewRequest(version, serial, env, partnerId, token string) *Request {
return &Request{
ClientVersion: version,
ClientSerial: serial,
@@ -28,6 +29,7 @@ func NewRequest(version, serial, env, partnerId string) *Request {
ClientCPU: runtime.NumCPU(),
ClientEnv: env,
PartnerID: partnerId,
ApiToken: token,
}
}

View File

@@ -57,5 +57,5 @@ func Msg(id Message, params ...interface{}) string {
}
func Error(id Message, params ...interface{}) error {
return errors.New(strings.ToLower(Msg(id, params...)))
return errors.New(Msg(id, params...))
}

View File

@@ -58,20 +58,20 @@ func TestMsg(t *testing.T) {
func TestError(t *testing.T) {
t.Run("already exists", func(t *testing.T) {
err := Error(ErrAlreadyExists, "A cat")
assert.EqualError(t, err, "a cat already exists")
assert.EqualError(t, err, "A cat already exists")
})
t.Run("unexpected error", func(t *testing.T) {
err := Error(ErrUnexpected, "A cat")
assert.EqualError(t, err, "unexpected error, please try again")
assert.EqualError(t, err, "Unexpected error, please try again")
})
t.Run("already exists german", func(t *testing.T) {
SetLocale("de")
errGerman := Error(ErrAlreadyExists, "Eine Katze")
assert.EqualError(t, errGerman, "eine katze existiert bereits")
assert.EqualError(t, errGerman, "Eine Katze existiert bereits")
SetLocale("")
errDefault := Error(ErrAlreadyExists, "A cat")
assert.EqualError(t, errDefault, "a cat already exists")
assert.EqualError(t, errDefault, "A cat already exists")
})
}

View File

@@ -36,6 +36,7 @@ const (
ErrInvalidName
ErrBusy
ErrWakeupInterval
ErrAccountConnect
MsgChangesSaved
MsgAlbumCreated
@@ -117,6 +118,7 @@ var Messages = MessageMap{
ErrInvalidName: gettext("Invalid name"),
ErrBusy: gettext("Busy, please try again later"),
ErrWakeupInterval: gettext("The wakeup interval is %s, but must be 1h or less"),
ErrAccountConnect: gettext("Your account could not be connected"),
// Info and confirmation messages:
MsgChangesSaved: gettext("Changes successfully saved"),

View File

@@ -44,7 +44,7 @@ func (w *Faces) Start(opt FacesOptions) (err error) {
}()
if w.Disabled() {
return fmt.Errorf("facial recognition is disabled")
return fmt.Errorf("face recognition is disabled")
}
if err := mutex.FacesWorker.Start(); err != nil {
@@ -161,7 +161,7 @@ func (w *Faces) Canceled() bool {
return mutex.FacesWorker.Canceled() || mutex.MainWorker.Canceled() || mutex.MetaWorker.Canceled()
}
// Disabled tests if facial recognition is disabled.
// Disabled tests if face recognition is disabled.
func (w *Faces) Disabled() bool {
return w.conf.DisableFaces()
}

View File

@@ -14,7 +14,7 @@ import (
// Cluster clusters indexed face embeddings.
func (w *Faces) Cluster(opt FacesOptions) (added entity.Faces, err error) {
if w.Disabled() {
return added, fmt.Errorf("facial recognition is disabled")
return added, fmt.Errorf("face recognition is disabled")
}
// Skip clustering if index contains no new face markers, and force option isn't set.

View File

@@ -27,7 +27,7 @@ func (r *FacesMatchResult) Add(result FacesMatchResult) {
// Match matches markers with faces and subjects.
func (w *Faces) Match(opt FacesOptions) (result FacesMatchResult, err error) {
if w.Disabled() {
return result, fmt.Errorf("facial recognition is disabled")
return result, fmt.Errorf("face recognition is disabled")
}
var unmatchedMarkers int

View File

@@ -16,7 +16,7 @@ type FacesOptimizeResult struct {
// Optimize optimizes the face lookup table.
func (w *Faces) Optimize() (result FacesOptimizeResult, err error) {
if w.Disabled() {
return result, fmt.Errorf("facial recognition is disabled")
return result, fmt.Errorf("face recognition is disabled")
}
// Iterative merging of manually added face clusters.

View File

@@ -247,9 +247,9 @@ func (imp *Import) Start(opt ImportOptions) fs.Done {
}
if filesImported > 0 {
// Run facial recognition if enabled.
// Run face recognition if enabled.
if w := NewFaces(imp.conf); w.Disabled() {
log.Debugf("import: skipping facial recognition")
log.Debugf("import: skipping face recognition")
} else if err := w.Start(FacesOptionsDefault()); err != nil {
log.Errorf("import: %s", err)
}

View File

@@ -261,9 +261,9 @@ func (ind *Index) Start(o IndexOptions) fs.Done {
"step": "faces",
})
// Run facial recognition if enabled.
// Run face recognition if enabled.
if w := NewFaces(ind.conf); w.Disabled() {
log.Debugf("index: skipping facial recognition")
log.Debugf("index: skipping face recognition")
} else if err := w.Start(FacesOptionsDefault()); err != nil {
log.Errorf("index: %s", err)
}

View File

@@ -396,7 +396,7 @@ func PhotosGeo(f form.SearchPhotosGeo) (results GeoResults, err error) {
return results, result.Error
}
log.Debugf("geo: found %s for %s [%s]", english.Plural(len(results), "result", "results"), f.SerializeAll(), time.Since(start))
log.Debugf("places: found %s for %s [%s]", english.Plural(len(results), "result", "results"), f.SerializeAll(), time.Since(start))
return results, nil
}

View File

@@ -171,6 +171,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetErrors(v1)
api.DeleteErrors(v1)
api.SendFeedback(v1)
api.Connect(v1)
api.Websocket(v1)
}

View File

@@ -44,11 +44,11 @@ func (m *Meta) Start(delay, interval time.Duration, force bool) (err error) {
defer mutex.MetaWorker.Stop()
log.Debugf("metadata: running facial recognition")
log.Debugf("metadata: running face recognition")
// Run faces worker.
if w := photoprism.NewFaces(m.conf); w.Disabled() {
log.Debugf("metadata: skipping facial recognition")
log.Debugf("metadata: skipping face recognition")
} else if err := w.Start(photoprism.FacesOptions{}); err != nil {
log.Warn(err)
}

37
pkg/rnd/crc_token.go Normal file
View File

@@ -0,0 +1,37 @@
package rnd
import (
"fmt"
"hash/crc32"
"strconv"
)
// CrcToken returns a string token with checksum.
func CrcToken() string {
token := make([]byte, 0, 14)
token = append(token, []byte(GenerateToken(4))...)
token = append(token, '-')
token = append(token, []byte(GenerateToken(4))...)
checksum := crc32.ChecksumIEEE(token)
sum := strconv.FormatInt(int64(checksum), 16)
return fmt.Sprintf("%s-%.4s", token, sum)
}
// ValidateCrcToken tests if the token string is valid.
func ValidateCrcToken(s string) bool {
if len(s) != 14 {
return false
}
token := []byte(s[:9])
checksum := crc32.ChecksumIEEE(token)
sum := strconv.FormatInt(int64(checksum), 16)
return s == fmt.Sprintf("%s-%.4s", token, sum)
}

33
pkg/rnd/crc_token_test.go Normal file
View File

@@ -0,0 +1,33 @@
package rnd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCrcToken(t *testing.T) {
t.Run("size 4", func(t *testing.T) {
token := CrcToken()
t.Logf("CrcToken: %s", token)
assert.NotEmpty(t, token)
})
}
func TestValidateCrcToken(t *testing.T) {
t.Run("Static", func(t *testing.T) {
assert.True(t, ValidateCrcToken("8yva-2ni4-385e"))
})
t.Run("Generated", func(t *testing.T) {
assert.True(t, ValidateCrcToken(CrcToken()))
})
t.Run("InvalidChecksum", func(t *testing.T) {
assert.False(t, ValidateCrcToken("8yva-2ni4-185e"))
})
t.Run("InvalidToken", func(t *testing.T) {
assert.False(t, ValidateCrcToken("8ava-2ni4-385e"))
})
t.Run("ValidToken", func(t *testing.T) {
assert.True(t, ValidateCrcToken("hsqq-181q-13ed"))
})
}