diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml
index 5eee1d47f..ff27a281b 100644
--- a/docker-compose.ci.yml
+++ b/docker-compose.ci.yml
@@ -19,8 +19,8 @@ services:
- "~/.cache/go-mod:/go/pkg/mod"
environment:
PHOTOPRISM_INIT: "https"
- PHOTOPRISM_ADMIN_USER: "admin" # admin username
- PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters)
+ PHOTOPRISM_ADMIN_USER: "admin" # superadmin username
+ PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters)
PHOTOPRISM_AUTH_MODE: "public" # authentication mode (public, password)
PHOTOPRISM_SITE_URL: "http://photoprism.me:2342/"
PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App"
diff --git a/docker-compose.latest.yml b/docker-compose.latest.yml
index 7ab84c28b..a057cb13c 100644
--- a/docker-compose.latest.yml
+++ b/docker-compose.latest.yml
@@ -24,8 +24,8 @@ services:
PHOTOPRISM_INIT: "https"
PHOTOPRISM_UID: ${UID:-1000} # user id, should match your host user id
PHOTOPRISM_GID: ${GID:-1000} # group id
- PHOTOPRISM_ADMIN_USER: "admin" # admin username
- PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters)
+ PHOTOPRISM_ADMIN_USER: "admin" # superadmin username
+ PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters)
PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password)
PHOTOPRISM_SITE_URL: "https://latest.localssl.dev/" # server URL in the format "http(s)://domain.name(:port)/(path)"
PHOTOPRISM_SITE_CAPTION: "Latest"
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index ef9d3161a..6d462cf78 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -24,8 +24,8 @@ services:
environment:
PHOTOPRISM_UID: ${UID:-1000} # user id, should match your host user id
PHOTOPRISM_GID: ${GID:-1000} # group id
- PHOTOPRISM_ADMIN_USER: "admin" # admin username
- PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters)
+ PHOTOPRISM_ADMIN_USER: "admin" # superadmin username
+ PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters)
PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password)
PHOTOPRISM_SITE_URL: "https://latest.localssl.dev/" # server URL in the format "http(s)://domain.name(:port)/(path)"
PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App"
diff --git a/docker-compose.postgres.yml b/docker-compose.postgres.yml
index 5e55c29b4..f542543cd 100644
--- a/docker-compose.postgres.yml
+++ b/docker-compose.postgres.yml
@@ -27,8 +27,8 @@ services:
shm_size: "2gb"
environment:
PHOTOPRISM_INIT: "https"
- PHOTOPRISM_ADMIN_USER: "admin" # admin username
- PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters)
+ PHOTOPRISM_ADMIN_USER: "admin" # superadmin username
+ PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters)
PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password)
PHOTOPRISM_SITE_URL: "http://photoprism.me:2342/"
PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App"
diff --git a/docker-compose.yml b/docker-compose.yml
index 487afd270..c997397d2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,8 +24,8 @@ services:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
- "traefik:keycloak.localssl.dev"
- - "traefik:dummy-webdav.localssl.dev"
- "traefik:dummy-oidc.localssl.dev"
+ - "traefik:dummy-webdav.localssl.dev"
labels:
- "traefik.enable=true"
- "traefik.http.services.photoprism.loadbalancer.server.port=2342"
@@ -39,9 +39,26 @@ services:
## Run as a non-root user after initialization (supported: 0, 33, 50-99, 500-600, and 900-1200):
PHOTOPRISM_UID: ${UID:-1000} # user id, should match your host user id
PHOTOPRISM_GID: ${GID:-1000} # group id
- PHOTOPRISM_ADMIN_USER: "admin" # admin username
- PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (minimum 8 characters)
+ ## Access Management
+ PHOTOPRISM_ADMIN_USER: "admin" # superadmin username
+ PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial superadmin password (minimum 8 characters)
PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password)
+ PHOTOPRISM_REGISTER_URI: "https://keycloak.localssl.dev/admin/"
+ PHOTOPRISM_PASSWORD_RESET_URI: "https://keycloak.localssl.dev/realms/master/login-actions/reset-credentials"
+ ## LDAP Authentication (pre-configured for local tests):
+ PHOTOPRISM_LDAP_URI: "ldaps://dummy-ldap:1636"
+ PHOTOPRISM_LDAP_INSECURE: "true"
+ PHOTOPRISM_LDAP_ROLE: "user"
+ PHOTOPRISM_LDAP_WEBDAV: "true"
+ PHOTOPRISM_LDAP_BIND: "simple"
+ PHOTOPRISM_LDAP_BIND_DN: "cn"
+ PHOTOPRISM_LDAP_BASE_DN: "dc=localssl,dc=dev"
+ ## OpenID Connect (pre-configured for local tests):
+ PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/auth/realms/master"
+ PHOTOPRISM_OIDC_INSECURE: "true"
+ PHOTOPRISM_OIDC_CLIENT: "photoprism-develop"
+ PHOTOPRISM_OIDC_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9"
+ ## Site Information
PHOTOPRISM_SITE_URL: "http://photoprism.me:2342/" # server URL in the format "http(s)://domain.name(:port)/(path)"
PHOTOPRISM_SITE_CAPTION: "AI-Powered Photos App"
PHOTOPRISM_SITE_DESCRIPTION: "Tags and finds pictures without getting in your way!"
@@ -83,10 +100,6 @@ services:
PHOTOPRISM_JPEG_SIZE: 7680 # size limit for converted image files in pixels (720-30000)
PHOTOPRISM_JPEG_QUALITY: 85 # a higher value increases the quality and file size of JPEG images and thumbnails (25-100)
TF_CPP_MIN_LOG_LEVEL: 0 # show TensorFlow log messages for development
- ## OpenID Connect Provider (pre-configured for local Keycloak test server):
- PHOTOPRISM_OIDC_ISSUER_URL: "https://keycloak.localssl.dev/auth/realms/master"
- PHOTOPRISM_OIDC_CLIENT_ID: "photoprism-develop"
- PHOTOPRISM_OIDC_CLIENT_SECRET: "9d8351a0-ca01-4556-9c37-85eb634869b9"
## Run/install on first startup (options: update https gpu tensorflow davfs clitools clean):
PHOTOPRISM_INIT: "https tensorflow"
## Hardware Video Transcoding (optional):
@@ -151,6 +164,7 @@ services:
keycloak:
image: quay.io/keycloak/keycloak:19.0
command: "start-dev" # development mode, do not use this in production!
+ container_name: keycloak
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
@@ -174,9 +188,32 @@ services:
KC_DB_USERNAME: "keycloak"
KC_DB_PASSWORD: "keycloak"
+ ## Dummy LDAP Server
+ dummy-ldap:
+ image: openidentityplatform/opendj:latest
+ container_name: dummy-ldap
+ expose:
+ - 1389
+ - 1636
+ - 4444
+ # ports:
+ # - "1389:1389"
+ # - "1636:1636"
+ # - "4444:4444"
+ user: "1001:1000"
+ environment:
+ OPENDJ_USER: 1001
+ PORT: 1389
+ LDAPS_PORT: 1636
+ BASE_DN: "dc=localssl,dc=dev"
+ ADD_BASE_ENTRY: "--addBaseEntry"
+ ROOT_USER_DN: "cn=user"
+ ROOT_PASSWORD: "photoprism"
+
## Dummy OpenID Connect Provider
dummy-oidc:
image: photoprism/dummy-oidc:220405
+ container_name: dummy-oidc
labels:
- "traefik.enable=true"
- "traefik.http.services.dummy-oidc.loadbalancer.server.port=9998"
@@ -189,6 +226,7 @@ services:
## Dummy WebDAV Server
dummy-webdav:
image: photoprism/dummy-webdav:220405
+ container_name: dummy-webdav
environment:
WEBDAV_USERNAME: admin
WEBDAV_PASSWORD: photoprism
diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js
index 7f56a33c5..0b12488bf 100644
--- a/frontend/src/common/config.js
+++ b/frontend/src/common/config.js
@@ -61,6 +61,7 @@ export default class Config {
this.themeName = "";
this.baseUri = "";
this.staticUri = "/static";
+ this.loginUri = "/library/login";
this.apiUri = "/api/v1";
this.contentUri = this.apiUri;
this.values = {
@@ -75,6 +76,7 @@ export default class Config {
} else {
this.baseUri = values.baseUri ? values.baseUri : "";
this.staticUri = values.staticUri ? values.staticUri : this.baseUri + "/static";
+ this.loginUri = values.loginUri ? values.loginUri : this.baseUri + "/library/login";
this.apiUri = values.apiUri ? values.apiUri : this.baseUri + "/api/v1";
this.contentUri = values.contentUri ? values.contentUri : this.apiUri;
}
diff --git a/frontend/src/common/session.js b/frontend/src/common/session.js
index dd62c6899..321a9e969 100644
--- a/frontend/src/common/session.js
+++ b/frontend/src/common/session.js
@@ -142,6 +142,7 @@ export default class Session {
deleteId() {
this.session_id = null;
+ this.provider = "";
this.storage.removeItem("session_id");
delete Api.defaults.headers.common[SessionHeader];
@@ -157,6 +158,9 @@ export default class Session {
if (resp.data.id) {
this.setId(resp.data.id);
}
+ if (resp.data.provider) {
+ this.provider = resp.data.provider;
+ }
if (resp.data.config) {
this.setConfig(resp.data.config);
}
diff --git a/frontend/src/css/layout.css b/frontend/src/css/layout.css
index 6e3339714..ae291702d 100644
--- a/frontend/src/css/layout.css
+++ b/frontend/src/css/layout.css
@@ -44,6 +44,30 @@
max-width: 1264px;
}
+.width-50 {
+ min-width: 50%;
+}
+
+.width-60 {
+ min-width: 60%;
+}
+
+.width-66 {
+ min-width: 66%;
+}
+
+.width-70 {
+ min-width: 70%;
+}
+
+.width-80 {
+ min-width: 80%;
+}
+
+.width-90 {
+ min-width: 90%;
+}
+
/* Rounded Elements */
.v-progress-linear,
diff --git a/frontend/src/dialog/account/password.vue b/frontend/src/dialog/account/password.vue
index 0495b9587..02480385e 100644
--- a/frontend/src/dialog/account/password.vue
+++ b/frontend/src/dialog/account/password.vue
@@ -44,7 +44,7 @@
:label="$gettext('New Password')"
class="input-new-password"
color="secondary-dark"
- :hint="$gettext('Must have at least 8 characters.')"
+ :hint="$gettextInterpolate($gettext('Must have at least %{n} characters.'), {n: passwordLength})"
>
@@ -101,6 +101,7 @@ export default {
oldPassword: "",
newPassword: "",
confirmPassword: "",
+ passwordLength: this.$config.get("passwordLength"),
rtl: this.$rtl,
};
},
@@ -112,7 +113,7 @@ export default {
},
methods: {
disabled() {
- return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < 8 || (this.newPassword !== this.confirmPassword));
+ return (this.isDemo || this.busy || this.oldPassword === "" || this.newPassword.length < this.passwordLength || (this.newPassword !== this.confirmPassword));
},
confirm() {
this.busy = true;
diff --git a/frontend/src/dialog/photo/edit/details.vue b/frontend/src/dialog/photo/edit/details.vue
index 709b9421c..18f0ea340 100644
--- a/frontend/src/dialog/photo/edit/details.vue
+++ b/frontend/src/dialog/photo/edit/details.vue
@@ -31,7 +31,7 @@
:disabled="disabled"
:rules="[textRule]"
hide-details box flat
- :label="$gettext('Title')"
+ :label="$pgettext('Photo', 'Title')"
placeholder=""
color="secondary-dark"
browser-autocomplete="off"
diff --git a/frontend/src/page/login.vue b/frontend/src/page/login.vue
index 6845b2915..aa40e3cca 100644
--- a/frontend/src/page/login.vue
+++ b/frontend/src/page/login.vue
@@ -45,13 +45,19 @@
>
-
-
+
+ Create Account
+
+
Sign in
arrow_forward
+
@@ -103,7 +109,8 @@ export default {
return {
colors: {
accent: "#05dde1",
- primary: "#00adb0",
+ primary: "#00a6a9",
+ secondary: "#505050",
link: "#c8e3e7",
},
loading: false,
@@ -115,6 +122,8 @@ export default {
siteDescription: this.$config.getSiteDescription(),
nextUrl: this.$route.params.nextUrl ? this.$route.params.nextUrl : "/",
wallpaperUri: this.$config.values.wallpaperUri,
+ registerUri: this.$config.values.registerUri,
+ passwordResetUri: this.$config.values.passwordResetUri,
rtl: this.$rtl,
};
},
@@ -146,6 +155,9 @@ export default {
setTimeout(() => { window.location = route.href; }, 100);
},
+ register() {
+ window.location = this.registerUri;
+ },
login() {
const username = this.username.trim();
const password = this.password.trim();
diff --git a/frontend/src/page/settings/account.vue b/frontend/src/page/settings/account.vue
index deb9777c3..d0ad05232 100644
--- a/frontend/src/page/settings/account.vue
+++ b/frontend/src/page/settings/account.vue
@@ -31,7 +31,7 @@
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
- :label="$gettext('Title')"
+ :label="$pgettext('Account', 'Title')"
class="input-name-title"
color="secondary-dark"
:rules="[v => validLength(v, 0, 32) || $gettext('Invalid')]"
diff --git a/go.mod b/go.mod
index f906a74ca..cb4ccef9d 100644
--- a/go.mod
+++ b/go.mod
@@ -97,6 +97,8 @@ require (
golang.org/x/time v0.2.0
)
+require github.com/go-ldap/ldap/v3 v3.4.4
+
require (
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect
@@ -105,6 +107,7 @@ require (
github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
+ github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -112,6 +115,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
diff --git a/go.sum b/go.sum
index 1118de13c..6218758c7 100644
--- a/go.sum
+++ b/go.sum
@@ -202,6 +202,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z
github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e h1:NeAW1fUYUEWhft7pkxDf6WoUvEZJ/uOKsvtpjLnn8MU=
+github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
@@ -382,6 +384,8 @@ github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-acme/lego/v4 v4.9.0 h1:8Hjj44IqRS7cigshMyFQ+0pIZvwgkG/+9A0UnNh7G8A=
github.com/go-acme/lego/v4 v4.9.0/go.mod h1:g3JRUyWS3L/VObpp4bCxzJftKyf/Wba8QrSSnoiqjg4=
+github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
+github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -405,6 +409,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
+github.com/go-ldap/ldap/v3 v3.4.4 h1:qPjipEpt+qDa6SI/h1fzuGWoRUY+qqQ9sOZq67/PYUs=
+github.com/go-ldap/ldap/v3 v3.4.4/go.mod h1:fe1MsuN5eJJ1FeLT/LEBVdWfNWKh459R7aXgXtJC+aI=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -918,6 +924,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
@@ -1022,6 +1029,7 @@ golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
diff --git a/internal/api/auth_session_create.go b/internal/api/auth_session_create.go
index 4df5be71e..31b6f3c53 100644
--- a/internal/api/auth_session_create.go
+++ b/internal/api/auth_session_create.go
@@ -32,11 +32,12 @@ func CreateSession(router *gin.RouterGroup) {
if conf.Public() {
sess := get.Session().Public()
data := gin.H{
- "status": "ok",
- "id": sess.ID,
- "user": sess.User(),
- "data": sess.Data(),
- "config": conf.ClientPublic(),
+ "status": "ok",
+ "id": sess.ID,
+ "provider": sess.AuthProvider,
+ "user": sess.User(),
+ "data": sess.Data(),
+ "config": conf.ClientPublic(),
}
c.JSON(http.StatusOK, data)
return
@@ -86,11 +87,12 @@ func CreateSession(router *gin.RouterGroup) {
// User information, session data, and client config values.
data := gin.H{
- "status": "ok",
- "id": sess.ID,
- "user": sess.User(),
- "data": sess.Data(),
- "config": clientConfig,
+ "status": "ok",
+ "id": sess.ID,
+ "provider": sess.AuthProvider,
+ "user": sess.User(),
+ "data": sess.Data(),
+ "config": clientConfig,
}
// Send JSON response.
diff --git a/internal/api/auth_session_get.go b/internal/api/auth_session_get.go
index c3a043e48..ecd55d97f 100644
--- a/internal/api/auth_session_get.go
+++ b/internal/api/auth_session_get.go
@@ -55,11 +55,12 @@ func GetSession(router *gin.RouterGroup) {
// Send JSON response with user information, session data, and client config values.
data := gin.H{
- "status": "ok",
- "id": sess.ID,
- "user": sess.User(),
- "data": sess.Data(),
- "config": get.Config().ClientSession(sess),
+ "status": "ok",
+ "id": sess.ID,
+ "provider": sess.AuthProvider,
+ "user": sess.User(),
+ "data": sess.Data(),
+ "config": get.Config().ClientSession(sess),
}
c.JSON(http.StatusOK, data)
diff --git a/internal/api/config_settings.go b/internal/api/config_settings.go
index a6663c58d..30515bd1c 100644
--- a/internal/api/config_settings.go
+++ b/internal/api/config_settings.go
@@ -58,8 +58,8 @@ func SaveSettings(router *gin.RouterGroup) {
var settings *customize.Settings
- if s.User().IsAdmin() {
- // Only admins may change the global config.
+ // Only super admins can change global config defaults.
+ if s.User().IsSuperAdmin() {
settings = conf.Settings()
if err := c.BindJSON(settings); err != nil {
diff --git a/internal/commands/users_add.go b/internal/commands/users_add.go
index df057cc98..8fbb110ad 100644
--- a/internal/commands/users_add.go
+++ b/internal/commands/users_add.go
@@ -64,10 +64,10 @@ func usersAddAction(ctx *cli.Context) error {
frm.UserEmail = clean.Email(res)
}
- if interactive && len(ctx.String("password")) < entity.LenPasswordMin {
+ if interactive && len(ctx.String("password")) < entity.PasswordLength {
validate := func(input string) error {
- if len(input) < entity.LenPasswordMin {
- return fmt.Errorf("password must have at least %d characters", entity.LenPasswordMin)
+ if len(input) < entity.PasswordLength {
+ return fmt.Errorf("password must have at least %d characters", entity.PasswordLength)
}
return nil
}
diff --git a/internal/commands/users_list.go b/internal/commands/users_list.go
index 0ac1ad1d4..3568858e6 100644
--- a/internal/commands/users_list.go
+++ b/internal/commands/users_list.go
@@ -23,7 +23,7 @@ var UsersListCommand = cli.Command{
// usersListAction displays existing user accounts.
func usersListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
- cols := []string{"ID", "UID", "User Name", "Display Name", "Email", "Role", "Super Admin", "Web Login", "WebDAV", "Attributes", "Created At"}
+ cols := []string{"User", "Login", "Full Name", "Email", "Role", "Super Admin", "Web UI", "WebDAV", "Attributes", "Created At"}
// Fetch users from database.
users := query.RegisteredUsers()
@@ -35,9 +35,8 @@ func usersListAction(ctx *cli.Context) error {
// Display report.
for i, user := range users {
rows[i] = []string{
- fmt.Sprintf("%d", user.ID),
user.UID(),
- user.Name(),
+ user.Login(),
user.FullName(),
user.Email(),
user.AclRole().String(),
diff --git a/internal/config/client_config.go b/internal/config/client_config.go
index f3de0d101..22d59f098 100644
--- a/internal/config/client_config.go
+++ b/internal/config/client_config.go
@@ -23,64 +23,68 @@ const (
// ClientConfig represents HTTP client / Web UI config options.
type ClientConfig struct {
- Mode string `json:"mode"`
- Name string `json:"name"`
- About string `json:"about"`
- Edition string `json:"edition"`
- Version string `json:"version"`
- Copyright string `json:"copyright"`
- Flags string `json:"flags"`
- BaseUri string `json:"baseUri"`
- StaticUri string `json:"staticUri"`
- CssUri string `json:"cssUri"`
- JsUri string `json:"jsUri"`
- ManifestUri string `json:"manifestUri"`
- ApiUri string `json:"apiUri"`
- ContentUri string `json:"contentUri"`
- WallpaperUri string `json:"wallpaperUri"`
- SiteUrl string `json:"siteUrl"`
- SiteDomain string `json:"siteDomain"`
- SiteAuthor string `json:"siteAuthor"`
- SiteTitle string `json:"siteTitle"`
- SiteCaption string `json:"siteCaption"`
- SiteDescription string `json:"siteDescription"`
- SitePreview string `json:"sitePreview"`
- LegalInfo string `json:"legalInfo"`
- LegalUrl string `json:"legalUrl"`
- AppName string `json:"appName"`
- AppMode string `json:"appMode"`
- AppIcon string `json:"appIcon"`
- Debug bool `json:"debug"`
- Trace bool `json:"trace"`
- Test bool `json:"test"`
- Demo bool `json:"demo"`
- Sponsor bool `json:"sponsor"`
- ReadOnly bool `json:"readonly"`
- UploadNSFW bool `json:"uploadNSFW"`
- Public bool `json:"public"`
- AuthMode string `json:"authMode"`
- Experimental bool `json:"experimental"`
- AlbumCategories []string `json:"albumCategories"`
- Albums entity.Albums `json:"albums"`
- Cameras entity.Cameras `json:"cameras"`
- Lenses entity.Lenses `json:"lenses"`
- Countries entity.Countries `json:"countries"`
- People entity.People `json:"people"`
- Thumbs ThumbSizes `json:"thumbs"`
- MapKey string `json:"mapKey"`
- DownloadToken string `json:"downloadToken,omitempty"`
- PreviewToken string `json:"previewToken,omitempty"`
- Disable ClientDisable `json:"disable"`
- Count ClientCounts `json:"count"`
- Pos ClientPosition `json:"pos"`
- Years Years `json:"years"`
- Colors []map[string]string `json:"colors"`
- Categories CategoryLabels `json:"categories"`
- Clip int `json:"clip"`
- Server env.Resources `json:"server"`
- Settings *customize.Settings `json:"settings,omitempty"`
- ACL acl.Grants `json:"acl,omitempty"`
- Ext Values `json:"ext"`
+ Mode string `json:"mode"`
+ Name string `json:"name"`
+ About string `json:"about"`
+ Edition string `json:"edition"`
+ Version string `json:"version"`
+ Copyright string `json:"copyright"`
+ Flags string `json:"flags"`
+ BaseUri string `json:"baseUri"`
+ StaticUri string `json:"staticUri"`
+ CssUri string `json:"cssUri"`
+ JsUri string `json:"jsUri"`
+ ManifestUri string `json:"manifestUri"`
+ ApiUri string `json:"apiUri"`
+ ContentUri string `json:"contentUri"`
+ WallpaperUri string `json:"wallpaperUri"`
+ SiteUrl string `json:"siteUrl"`
+ SiteDomain string `json:"siteDomain"`
+ SiteAuthor string `json:"siteAuthor"`
+ SiteTitle string `json:"siteTitle"`
+ SiteCaption string `json:"siteCaption"`
+ SiteDescription string `json:"siteDescription"`
+ SitePreview string `json:"sitePreview"`
+ LegalInfo string `json:"legalInfo"`
+ LegalUrl string `json:"legalUrl"`
+ AppName string `json:"appName"`
+ AppMode string `json:"appMode"`
+ AppIcon string `json:"appIcon"`
+ Debug bool `json:"debug"`
+ Trace bool `json:"trace"`
+ Test bool `json:"test"`
+ Demo bool `json:"demo"`
+ Sponsor bool `json:"sponsor"`
+ ReadOnly bool `json:"readonly"`
+ UploadNSFW bool `json:"uploadNSFW"`
+ Public bool `json:"public"`
+ AuthMode string `json:"authMode"`
+ LoginUri string `json:"loginUri"`
+ RegisterUri string `json:"registerUri"`
+ PasswordLength int `json:"passwordLength"`
+ PasswordResetUri string `json:"passwordResetUri"`
+ Experimental bool `json:"experimental"`
+ AlbumCategories []string `json:"albumCategories"`
+ Albums entity.Albums `json:"albums"`
+ Cameras entity.Cameras `json:"cameras"`
+ Lenses entity.Lenses `json:"lenses"`
+ Countries entity.Countries `json:"countries"`
+ People entity.People `json:"people"`
+ Thumbs ThumbSizes `json:"thumbs"`
+ MapKey string `json:"mapKey"`
+ DownloadToken string `json:"downloadToken,omitempty"`
+ PreviewToken string `json:"previewToken,omitempty"`
+ Disable ClientDisable `json:"disable"`
+ Count ClientCounts `json:"count"`
+ Pos ClientPosition `json:"pos"`
+ Years Years `json:"years"`
+ Colors []map[string]string `json:"colors"`
+ Categories CategoryLabels `json:"categories"`
+ Clip int `json:"clip"`
+ Server env.Resources `json:"server"`
+ Settings *customize.Settings `json:"settings,omitempty"`
+ ACL acl.Grants `json:"acl,omitempty"`
+ Ext Values `json:"ext"`
}
// ApplyACL updates the client config values based on the ACL and Role provided.
@@ -225,54 +229,57 @@ func (c *Config) ClientPublic() ClientConfig {
Faces: true,
Classification: true,
},
- Flags: strings.Join(c.Flags(), " "),
- Mode: string(ClientPublic),
- Name: c.Name(),
- About: c.Edition(),
- Edition: c.Hub().Status,
- BaseUri: c.BaseUri(""),
- StaticUri: c.StaticUri(),
- CssUri: a.AppCssUri(),
- JsUri: a.AppJsUri(),
- ApiUri: c.ApiUri(),
- ContentUri: c.ContentUri(),
- SiteUrl: c.SiteUrl(),
- SiteDomain: c.SiteDomain(),
- SiteAuthor: c.SiteAuthor(),
- SiteTitle: c.SiteTitle(),
- SiteCaption: c.SiteCaption(),
- SiteDescription: c.SiteDescription(),
- SitePreview: c.SitePreview(),
- LegalInfo: c.LegalInfo(),
- LegalUrl: c.LegalUrl(),
- AppName: c.AppName(),
- AppMode: c.AppMode(),
- AppIcon: c.AppIcon(),
- WallpaperUri: c.WallpaperUri(),
- Version: c.Version(),
- Copyright: c.Copyright(),
- Debug: c.Debug(),
- Trace: c.Trace(),
- Test: c.Test(),
- Demo: c.Demo(),
- Sponsor: c.Sponsor(),
- ReadOnly: c.ReadOnly(),
- Public: c.Public(),
- AuthMode: c.AuthMode(),
- Experimental: c.Experimental(),
- Albums: entity.Albums{},
- Cameras: entity.Cameras{},
- Lenses: entity.Lenses{},
- Countries: entity.Countries{},
- People: entity.People{},
- MapKey: "",
- Thumbs: Thumbs,
- Colors: colors.All.List(),
- ManifestUri: c.ClientManifestUri(),
- Clip: txt.ClipDefault,
- PreviewToken: entity.TokenPublic,
- DownloadToken: entity.TokenPublic,
- Ext: ClientExt(c, ClientPublic),
+ Flags: strings.Join(c.Flags(), " "),
+ Mode: string(ClientPublic),
+ Name: c.Name(),
+ About: c.Edition(),
+ Edition: c.Hub().Status,
+ BaseUri: c.BaseUri(""),
+ StaticUri: c.StaticUri(),
+ CssUri: a.AppCssUri(),
+ JsUri: a.AppJsUri(),
+ ApiUri: c.ApiUri(),
+ ContentUri: c.ContentUri(),
+ SiteUrl: c.SiteUrl(),
+ SiteDomain: c.SiteDomain(),
+ SiteAuthor: c.SiteAuthor(),
+ SiteTitle: c.SiteTitle(),
+ SiteCaption: c.SiteCaption(),
+ SiteDescription: c.SiteDescription(),
+ SitePreview: c.SitePreview(),
+ LegalInfo: c.LegalInfo(),
+ LegalUrl: c.LegalUrl(),
+ AppName: c.AppName(),
+ AppMode: c.AppMode(),
+ AppIcon: c.AppIcon(),
+ WallpaperUri: c.WallpaperUri(),
+ Version: c.Version(),
+ Copyright: c.Copyright(),
+ Debug: c.Debug(),
+ Trace: c.Trace(),
+ Test: c.Test(),
+ Demo: c.Demo(),
+ Sponsor: c.Sponsor(),
+ ReadOnly: c.ReadOnly(),
+ Public: c.Public(),
+ AuthMode: c.AuthMode(),
+ LoginUri: c.LoginUri(),
+ RegisterUri: c.RegisterUri(),
+ PasswordResetUri: c.PasswordResetUri(),
+ Experimental: c.Experimental(),
+ Albums: entity.Albums{},
+ Cameras: entity.Cameras{},
+ Lenses: entity.Lenses{},
+ Countries: entity.Countries{},
+ People: entity.People{},
+ MapKey: "",
+ Thumbs: Thumbs,
+ Colors: colors.All.List(),
+ ManifestUri: c.ClientManifestUri(),
+ Clip: txt.ClipDefault,
+ PreviewToken: entity.TokenPublic,
+ DownloadToken: entity.TokenPublic,
+ Ext: ClientExt(c, ClientPublic),
}
return cfg
@@ -301,55 +308,58 @@ func (c *Config) ClientShare() ClientConfig {
Faces: true,
Classification: true,
},
- Flags: strings.Join(c.Flags(), " "),
- Mode: string(ClientShare),
- Name: c.Name(),
- About: c.Edition(),
- Edition: c.Hub().Status,
- BaseUri: c.BaseUri(""),
- StaticUri: c.StaticUri(),
- CssUri: a.AppCssUri(),
- JsUri: a.ShareJsUri(),
- ApiUri: c.ApiUri(),
- ContentUri: c.ContentUri(),
- SiteUrl: c.SiteUrl(),
- SiteDomain: c.SiteDomain(),
- SiteAuthor: c.SiteAuthor(),
- SiteTitle: c.SiteTitle(),
- SiteCaption: c.SiteCaption(),
- SiteDescription: c.SiteDescription(),
- SitePreview: c.SitePreview(),
- LegalInfo: c.LegalInfo(),
- LegalUrl: c.LegalUrl(),
- AppName: c.AppName(),
- AppMode: c.AppMode(),
- AppIcon: c.AppIcon(),
- WallpaperUri: c.WallpaperUri(),
- Version: c.Version(),
- Copyright: c.Copyright(),
- Debug: c.Debug(),
- Trace: c.Trace(),
- Test: c.Test(),
- Demo: c.Demo(),
- Sponsor: c.Sponsor(),
- ReadOnly: c.ReadOnly(),
- UploadNSFW: c.UploadNSFW(),
- Public: c.Public(),
- AuthMode: c.AuthMode(),
- Experimental: c.Experimental(),
- Albums: entity.Albums{},
- Cameras: entity.Cameras{},
- Lenses: entity.Lenses{},
- Countries: entity.Countries{},
- People: entity.People{},
- Colors: colors.All.List(),
- Thumbs: Thumbs,
- MapKey: c.Hub().MapKey(),
- DownloadToken: c.DownloadToken(),
- PreviewToken: c.PreviewToken(),
- ManifestUri: c.ClientManifestUri(),
- Clip: txt.ClipDefault,
- Ext: ClientExt(c, ClientShare),
+ Flags: strings.Join(c.Flags(), " "),
+ Mode: string(ClientShare),
+ Name: c.Name(),
+ About: c.Edition(),
+ Edition: c.Hub().Status,
+ BaseUri: c.BaseUri(""),
+ StaticUri: c.StaticUri(),
+ CssUri: a.AppCssUri(),
+ JsUri: a.ShareJsUri(),
+ ApiUri: c.ApiUri(),
+ ContentUri: c.ContentUri(),
+ SiteUrl: c.SiteUrl(),
+ SiteDomain: c.SiteDomain(),
+ SiteAuthor: c.SiteAuthor(),
+ SiteTitle: c.SiteTitle(),
+ SiteCaption: c.SiteCaption(),
+ SiteDescription: c.SiteDescription(),
+ SitePreview: c.SitePreview(),
+ LegalInfo: c.LegalInfo(),
+ LegalUrl: c.LegalUrl(),
+ AppName: c.AppName(),
+ AppMode: c.AppMode(),
+ AppIcon: c.AppIcon(),
+ WallpaperUri: c.WallpaperUri(),
+ Version: c.Version(),
+ Copyright: c.Copyright(),
+ Debug: c.Debug(),
+ Trace: c.Trace(),
+ Test: c.Test(),
+ Demo: c.Demo(),
+ Sponsor: c.Sponsor(),
+ ReadOnly: c.ReadOnly(),
+ UploadNSFW: c.UploadNSFW(),
+ Public: c.Public(),
+ AuthMode: c.AuthMode(),
+ LoginUri: c.LoginUri(),
+ RegisterUri: c.RegisterUri(),
+ PasswordResetUri: c.PasswordResetUri(),
+ Experimental: c.Experimental(),
+ Albums: entity.Albums{},
+ Cameras: entity.Cameras{},
+ Lenses: entity.Lenses{},
+ Countries: entity.Countries{},
+ People: entity.People{},
+ Colors: colors.All.List(),
+ Thumbs: Thumbs,
+ MapKey: c.Hub().MapKey(),
+ DownloadToken: c.DownloadToken(),
+ PreviewToken: c.PreviewToken(),
+ ManifestUri: c.ClientManifestUri(),
+ Clip: txt.ClipDefault,
+ Ext: ClientExt(c, ClientShare),
}
return cfg
@@ -383,56 +393,60 @@ func (c *Config) ClientUser(withSettings bool) ClientConfig {
Faces: c.DisableFaces(),
Classification: c.DisableClassification(),
},
- Flags: strings.Join(c.Flags(), " "),
- Mode: string(ClientUser),
- Name: c.Name(),
- About: c.Edition(),
- Edition: c.Hub().Status,
- BaseUri: c.BaseUri(""),
- StaticUri: c.StaticUri(),
- CssUri: a.AppCssUri(),
- JsUri: a.AppJsUri(),
- ApiUri: c.ApiUri(),
- ContentUri: c.ContentUri(),
- SiteUrl: c.SiteUrl(),
- SiteDomain: c.SiteDomain(),
- SiteAuthor: c.SiteAuthor(),
- SiteTitle: c.SiteTitle(),
- SiteCaption: c.SiteCaption(),
- SiteDescription: c.SiteDescription(),
- SitePreview: c.SitePreview(),
- LegalInfo: c.LegalInfo(),
- LegalUrl: c.LegalUrl(),
- AppName: c.AppName(),
- AppMode: c.AppMode(),
- AppIcon: c.AppIcon(),
- WallpaperUri: c.WallpaperUri(),
- Version: c.Version(),
- Copyright: c.Copyright(),
- Debug: c.Debug(),
- Trace: c.Trace(),
- Test: c.Test(),
- Demo: c.Demo(),
- Sponsor: c.Sponsor(),
- ReadOnly: c.ReadOnly(),
- UploadNSFW: c.UploadNSFW(),
- Public: c.Public(),
- AuthMode: c.AuthMode(),
- Experimental: c.Experimental(),
- Albums: entity.Albums{},
- Cameras: entity.Cameras{},
- Lenses: entity.Lenses{},
- Countries: entity.Countries{},
- People: entity.People{},
- Colors: colors.All.List(),
- Thumbs: Thumbs,
- MapKey: c.Hub().MapKey(),
- DownloadToken: c.DownloadToken(),
- PreviewToken: c.PreviewToken(),
- ManifestUri: c.ClientManifestUri(),
- Clip: txt.ClipDefault,
- Server: env.Info(),
- Ext: ClientExt(c, ClientUser),
+ Flags: strings.Join(c.Flags(), " "),
+ Mode: string(ClientUser),
+ Name: c.Name(),
+ About: c.Edition(),
+ Edition: c.Hub().Status,
+ BaseUri: c.BaseUri(""),
+ StaticUri: c.StaticUri(),
+ CssUri: a.AppCssUri(),
+ JsUri: a.AppJsUri(),
+ ApiUri: c.ApiUri(),
+ ContentUri: c.ContentUri(),
+ SiteUrl: c.SiteUrl(),
+ SiteDomain: c.SiteDomain(),
+ SiteAuthor: c.SiteAuthor(),
+ SiteTitle: c.SiteTitle(),
+ SiteCaption: c.SiteCaption(),
+ SiteDescription: c.SiteDescription(),
+ SitePreview: c.SitePreview(),
+ LegalInfo: c.LegalInfo(),
+ LegalUrl: c.LegalUrl(),
+ AppName: c.AppName(),
+ AppMode: c.AppMode(),
+ AppIcon: c.AppIcon(),
+ WallpaperUri: c.WallpaperUri(),
+ Version: c.Version(),
+ Copyright: c.Copyright(),
+ Debug: c.Debug(),
+ Trace: c.Trace(),
+ Test: c.Test(),
+ Demo: c.Demo(),
+ Sponsor: c.Sponsor(),
+ ReadOnly: c.ReadOnly(),
+ UploadNSFW: c.UploadNSFW(),
+ Public: c.Public(),
+ AuthMode: c.AuthMode(),
+ LoginUri: c.LoginUri(),
+ RegisterUri: c.RegisterUri(),
+ PasswordLength: c.PasswordLength(),
+ PasswordResetUri: c.PasswordResetUri(),
+ Experimental: c.Experimental(),
+ Albums: entity.Albums{},
+ Cameras: entity.Cameras{},
+ Lenses: entity.Lenses{},
+ Countries: entity.Countries{},
+ People: entity.People{},
+ Colors: colors.All.List(),
+ Thumbs: Thumbs,
+ MapKey: c.Hub().MapKey(),
+ DownloadToken: c.DownloadToken(),
+ PreviewToken: c.PreviewToken(),
+ ManifestUri: c.ClientManifestUri(),
+ Clip: txt.ClipDefault,
+ Server: env.Info(),
+ Ext: ClientExt(c, ClientUser),
}
hidePrivate := c.Settings().Features.Private
diff --git a/internal/config/client_config_test.go b/internal/config/client_config_test.go
index 15fbb9196..e1679f9a9 100644
--- a/internal/config/client_config_test.go
+++ b/internal/config/client_config_test.go
@@ -17,6 +17,10 @@ func TestConfig_ClientConfig(t *testing.T) {
result := c.ClientPublic()
assert.IsType(t, ClientConfig{}, result)
assert.Equal(t, AuthModePublic, result.AuthMode)
+ assert.Equal(t, "", result.LoginUri)
+ assert.Equal(t, "", result.RegisterUri)
+ assert.Equal(t, 0, result.PasswordLength)
+ assert.Equal(t, "", result.PasswordResetUri)
assert.Equal(t, true, result.Public)
})
t.Run("TestErrorConfig", func(t *testing.T) {
diff --git a/internal/config/config.go b/internal/config/config.go
index a239ff56f..c21de8541 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -166,6 +166,9 @@ func (c *Config) Propagate() {
places.UserAgent = c.UserAgent()
entity.GeoApi = c.GeoApi()
+ // Set minimum password length.
+ entity.PasswordLength = c.PasswordLength()
+
// Set API preview and download default tokens.
entity.PreviewToken.Set(c.PreviewToken(), entity.TokenConfig)
entity.DownloadToken.Set(c.DownloadToken(), entity.TokenConfig)
@@ -673,7 +676,7 @@ func (c *Config) AutoImport() time.Duration {
return time.Duration(c.options.AutoImport) * time.Second
}
-// GeoApi returns the preferred geocoding api (none or places).
+// GeoApi returns the preferred geocoding api (places, or none).
func (c *Config) GeoApi() string {
if c.options.DisablePlaces {
return ""
diff --git a/internal/config/config_auth.go b/internal/config/config_auth.go
index 1399e1e7a..3a8adb60b 100644
--- a/internal/config/config_auth.go
+++ b/internal/config/config_auth.go
@@ -3,10 +3,9 @@ package config
import (
"regexp"
- "github.com/photoprism/photoprism/internal/entity"
-
"golang.org/x/crypto/bcrypt"
+ "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -85,6 +84,11 @@ func (c *Config) SetAuthMode(mode string) {
}
}
+// Auth checks if authentication is required.
+func (c *Config) Auth() bool {
+ return !c.Public()
+}
+
// AuthMode returns the authentication mode.
func (c *Config) AuthMode() string {
if c.options.Public || c.options.Demo {
@@ -99,9 +103,46 @@ func (c *Config) AuthMode() string {
}
}
-// Auth checks if authentication is required.
-func (c *Config) Auth() bool {
- return !c.Public()
+// LoginUri returns the user login URI.
+func (c *Config) LoginUri() string {
+ if c.Public() {
+ return ""
+ }
+
+ if c.options.LoginUri == "" {
+ return c.BaseUri("/library/login")
+ }
+
+ return c.options.LoginUri
+}
+
+// RegisterUri returns the user registration URI.
+func (c *Config) RegisterUri() string {
+ if c.Public() {
+ return ""
+ }
+
+ return c.options.RegisterUri
+}
+
+// PasswordLength returns the minimum password length in characters.
+func (c *Config) PasswordLength() int {
+ if c.Public() {
+ return 0
+ } else if c.options.PasswordLength < 1 {
+ return 4
+ }
+
+ return c.options.PasswordLength
+}
+
+// PasswordResetUri returns the password reset URI.
+func (c *Config) PasswordResetUri() string {
+ if c.Public() {
+ return ""
+ }
+
+ return c.options.PasswordResetUri
}
// CheckPassword compares given password p with the admin password
diff --git a/internal/config/config_auth_test.go b/internal/config/config_auth_test.go
index 0eb2a52dc..af87abd77 100644
--- a/internal/config/config_auth_test.go
+++ b/internal/config/config_auth_test.go
@@ -6,6 +6,18 @@ import (
"github.com/stretchr/testify/assert"
)
+func TestAuth(t *testing.T) {
+ c := NewConfig(CliTestContext())
+ c.options.Public = true
+ c.options.Demo = false
+ assert.False(t, c.Auth())
+ c.options.Public = false
+ c.options.Demo = false
+ assert.True(t, c.Auth())
+ c.options.Demo = true
+ assert.False(t, c.Auth())
+}
+
func TestAuthMode(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.Public = true
@@ -34,16 +46,24 @@ func TestAuthMode(t *testing.T) {
c.options.Debug = false
}
-func TestAuth(t *testing.T) {
+func TestLoginUri(t *testing.T) {
c := NewConfig(CliTestContext())
- c.options.Public = true
- c.options.Demo = false
- assert.False(t, c.Auth())
- c.options.Public = false
- c.options.Demo = false
- assert.True(t, c.Auth())
- c.options.Demo = true
- assert.False(t, c.Auth())
+ assert.Equal(t, "/library/login", c.LoginUri())
+}
+
+func TestRegisterUri(t *testing.T) {
+ c := NewConfig(CliTestContext())
+ assert.Equal(t, "", c.RegisterUri())
+}
+
+func TestPasswordLength(t *testing.T) {
+ c := NewConfig(CliTestContext())
+ assert.Equal(t, 4, c.PasswordLength())
+}
+
+func TestPasswordResetUri(t *testing.T) {
+ c := NewConfig(CliTestContext())
+ assert.Equal(t, "", c.PasswordResetUri())
}
func TestSessMaxAge(t *testing.T) {
diff --git a/internal/config/config_server.go b/internal/config/config_server.go
index ff125f8bc..928dead23 100644
--- a/internal/config/config_server.go
+++ b/internal/config/config_server.go
@@ -77,7 +77,7 @@ func (c *Config) HttpMode() string {
return c.options.HttpMode
}
-// HttpCompression returns the http compression method (none or gzip).
+// HttpCompression returns the http compression method (gzip, or none).
func (c *Config) HttpCompression() string {
return strings.ToLower(strings.TrimSpace(c.options.HttpCompression))
}
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 9a9a3ce2d..7c69f82d7 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -1,9 +1,12 @@
package config
import (
+ "fmt"
+
"github.com/klauspost/cpuid/v2"
"github.com/urfave/cli"
+ "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/face"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/header"
@@ -27,13 +30,13 @@ var Flags = CliFlags{
}}, {
Flag: cli.StringFlag{
Name: "admin-user, login",
- Usage: "admin login `USERNAME`",
+ Usage: "superadmin `USERNAME`",
EnvVar: "PHOTOPRISM_ADMIN_USER",
Value: "admin",
}}, {
Flag: cli.StringFlag{
Name: "admin-password, pw",
- Usage: "initial admin `PASSWORD`, must have at least 8 characters",
+ Usage: fmt.Sprintf("initial superadmin `PASSWORD` (minimum %d characters)", entity.PasswordLength),
EnvVar: "PHOTOPRISM_ADMIN_PASSWORD",
}}, {
Flag: cli.Int64Flag{
@@ -126,10 +129,7 @@ var Flags = CliFlags{
Value: DefaultResolutionLimit,
Usage: "maximum resolution of media files in `MEGAPIXELS` (1-900; -1 to disable)",
EnvVar: "PHOTOPRISM_RESOLUTION_LIMIT",
- },
- Tags: []string{EnvSponsor},
- },
- {
+ }, Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{
Name: "storage-path, s",
Usage: "writable storage `PATH` for sidecar, cache, and database files",
@@ -427,12 +427,12 @@ var Flags = CliFlags{
}}, {
Flag: cli.StringFlag{
Name: "http-mode, mode",
- Usage: "Web server `MODE` (debug, release, or test)",
+ Usage: "Web server `MODE` (debug, release, test)",
EnvVar: "PHOTOPRISM_HTTP_MODE",
}}, {
Flag: cli.StringFlag{
Name: "http-compression, z",
- Usage: "Web server compression `METHOD` (none or gzip)",
+ Usage: "Web server compression `METHOD` (gzip, none)",
EnvVar: "PHOTOPRISM_HTTP_COMPRESSION",
}}, {
Flag: cli.StringFlag{
diff --git a/internal/config/options.go b/internal/config/options.go
index b2f7099c5..67e3f59a7 100644
--- a/internal/config/options.go
+++ b/internal/config/options.go
@@ -23,6 +23,10 @@ type Options struct {
Copyright string `json:"-"`
PartnerID string `yaml:"-" json:"-" flag:"partner-id"`
AuthMode string `yaml:"AuthMode" json:"-" flag:"auth-mode"`
+ LoginUri string `yaml:"LoginUri" json:"-" flag:"login-uri"`
+ RegisterUri string `yaml:"RegisterUri" json:"-" flag:"register-uri"`
+ PasswordLength int `yaml:"PasswordLength" json:"-" flag:"password-length"`
+ PasswordResetUri string `yaml:"PasswordResetUri" json:"-" flag:"password-reset-uri"`
Public bool `yaml:"Public" json:"-" flag:"public"`
AdminUser string `yaml:"AdminUser" json:"-" flag:"admin-user"`
AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"`
diff --git a/internal/config/report.go b/internal/config/report.go
index 73f020d90..3fd220ed5 100644
--- a/internal/config/report.go
+++ b/internal/config/report.go
@@ -27,6 +27,10 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"public", fmt.Sprintf("%t", c.Public())},
{"session-maxage", fmt.Sprintf("%d", c.SessionMaxAge())},
{"session-timeout", fmt.Sprintf("%d", c.SessionTimeout())},
+ {"login-uri", c.LoginUri()},
+ {"register-uri", c.RegisterUri()},
+ {"password-length", fmt.Sprintf("%d", c.PasswordLength())},
+ {"password-reset-uri", c.PasswordResetUri()},
// Logging.
{"log-level", c.LogLevel().String()},
diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go
index 0ed0e4b4a..b8d23e9c0 100644
--- a/internal/entity/auth_session.go
+++ b/internal/entity/auth_session.go
@@ -257,6 +257,26 @@ func (m *Session) SetUser(u *User) *Session {
return m
}
+// Login returns the login name and provider.
+func (m *Session) Login() string {
+ if m.AuthProvider == "" {
+ return m.UserName
+ } else {
+ return fmt.Sprintf("%s@%s", m.UserName, m.AuthProvider)
+ }
+}
+
+// SetProvider updates the session's authentication provider.
+func (m *Session) SetProvider(provider string) *Session {
+ if provider == "" {
+ return m
+ }
+
+ m.AuthProvider = provider
+
+ return m
+}
+
// ChangePassword changes the password of the current user.
func (m *Session) ChangePassword(newPw string) (err error) {
u := m.User()
diff --git a/internal/entity/auth_session_login.go b/internal/entity/auth_session_login.go
index cc742849a..9245651ab 100644
--- a/internal/entity/auth_session_login.go
+++ b/internal/entity/auth_session_login.go
@@ -4,8 +4,6 @@ import (
"net/http"
"time"
- "github.com/photoprism/photoprism/pkg/txt"
-
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
@@ -13,61 +11,90 @@ import (
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/clean"
+ "github.com/photoprism/photoprism/pkg/txt"
)
+// Auth checks if the credentials are valid and returns the user and authentication provider.
+var Auth = func(f form.Login, m *Session, c *gin.Context) (user *User, provider string, err error) {
+ name := f.Name()
+
+ user = FindUserByName(name)
+ err = AuthPassword(user, f, m)
+
+ if err != nil {
+ return user, ProviderNone, err
+ }
+
+ return user, ProviderPassword, err
+}
+
+// AuthPassword checks if the username and password are valid and returns the user.
+func AuthPassword(user *User, f form.Login, m *Session) (err error) {
+ name := f.Name()
+
+ // User found?
+ if user == nil {
+ message := "account not found"
+ limiter.Login.Reserve(m.IP())
+ event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
+ event.LoginError(m.IP(), "api", name, m.UserAgent, message)
+ m.Status = http.StatusUnauthorized
+ return i18n.Error(i18n.ErrInvalidCredentials)
+ }
+
+ // Login allowed?
+ if !user.CanLogIn() {
+ message := "account disabled"
+ event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
+ event.LoginError(m.IP(), "api", name, m.UserAgent, message)
+ m.Status = http.StatusUnauthorized
+ return i18n.Error(i18n.ErrInvalidCredentials)
+ }
+
+ // Password valid?
+ if user.WrongPassword(f.Password) {
+ message := "incorrect password"
+ limiter.Login.Reserve(m.IP())
+ event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
+ event.LoginError(m.IP(), "api", name, m.UserAgent, message)
+ m.Status = http.StatusUnauthorized
+ return i18n.Error(i18n.ErrInvalidCredentials)
+ } else {
+ event.AuditInfo([]string{m.IP(), "session %s", "login as %s", "succeeded"}, m.RefID, clean.LogQuote(name))
+ event.LoginInfo(m.IP(), "api", name, m.UserAgent)
+ }
+
+ return err
+}
+
// LogIn performs authentication checks against the specified login form.
func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
if c != nil {
m.SetContext(c)
}
- // Username and password provided?
+ var user *User
+ var provider string
+
+ // Login credentials provided?
if f.HasCredentials() {
if m.IsRegistered() {
m.RegenerateID()
}
- name := f.Name()
- user := FindUserByName(name)
+ user, provider, err = Auth(f, m, c)
- // User found?
- if user == nil {
- message := "account not found"
- limiter.Login.Reserve(m.IP())
- event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
- event.LoginError(m.IP(), "api", name, m.UserAgent, message)
- m.Status = http.StatusUnauthorized
- return i18n.Error(i18n.ErrInvalidCredentials)
- }
-
- // Login allowed?
- if !user.CanLogIn() {
- message := "account disabled"
- event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
- event.LoginError(m.IP(), "api", name, m.UserAgent, message)
- m.Status = http.StatusUnauthorized
- return i18n.Error(i18n.ErrInvalidCredentials)
- }
-
- // Password valid?
- if user.WrongPassword(f.Password) {
- message := "incorrect password"
- limiter.Login.Reserve(m.IP())
- event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
- event.LoginError(m.IP(), "api", name, m.UserAgent, message)
- m.Status = http.StatusUnauthorized
- return i18n.Error(i18n.ErrInvalidCredentials)
- } else {
- event.AuditInfo([]string{m.IP(), "session %s", "login as %s", "succeeded"}, m.RefID, clean.LogQuote(name))
- event.LoginInfo(m.IP(), "api", name, m.UserAgent)
+ if err != nil {
+ return err
}
m.SetUser(user)
+ m.SetProvider(provider)
}
// Share token provided?
if f.HasToken() {
- user := m.User()
+ user = m.User()
// Redeem token.
if user.IsRegistered() {
diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go
index 449986daa..85237b3a8 100644
--- a/internal/entity/auth_user.go
+++ b/internal/entity/auth_user.go
@@ -8,9 +8,8 @@ import (
"strings"
"time"
- "github.com/ulule/deepcopier"
-
"github.com/jinzhu/gorm"
+ "github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
@@ -27,11 +26,11 @@ const (
OwnerUnknown = ""
)
-// LenNameMin specifies the minimum length of the username in characters.
-var LenNameMin = 3
+// UsernameLength specifies the minimum length of the username in characters.
+var UsernameLength = 1
-// LenPasswordMin specifies the minimum length of the password in characters.
-var LenPasswordMin = 4
+// PasswordLength specifies the minimum length of the password in characters.
+var PasswordLength = 4
// Users represents a list of users.
type Users []User
@@ -110,6 +109,8 @@ func FindUser(find User) *User {
stmt = stmt.Where("user_name = ?", find.UserName)
} else if find.UserEmail != "" {
stmt = stmt.Where("user_email = ?", find.UserEmail)
+ } else if find.AuthProvider != "" && find.AuthID != "" {
+ stmt = stmt.Where("auth_provider = ? AND auth_id = ?", find.AuthProvider, find.AuthID)
} else {
return nil
}
@@ -512,7 +513,16 @@ func (m *User) IsAdmin() bool {
return false
}
- return m.IsRegistered() && (m.SuperAdmin || m.AclRole() == acl.RoleAdmin)
+ return m.IsSuperAdmin() || m.IsRegistered() && m.AclRole() == acl.RoleAdmin
+}
+
+// IsSuperAdmin checks if the user is a super admin.
+func (m *User) IsSuperAdmin() bool {
+ if m == nil {
+ return false
+ }
+
+ return m.SuperAdmin
}
// IsVisitor checks if the user is a sharing link visitor.
@@ -559,8 +569,8 @@ func (m *User) SetPassword(password string) error {
return fmt.Errorf("only registered users can change their password")
}
- if len(password) < LenPasswordMin {
- return fmt.Errorf("password must have at least %d characters", LenPasswordMin)
+ if len(password) < PasswordLength {
+ return fmt.Errorf("password must have at least %d characters", PasswordLength)
}
pw := NewPassword(m.UserUID, password)
@@ -614,8 +624,8 @@ func (m *User) Validate() (err error) {
}
// Name too short?
- if len(m.Name()) < LenNameMin {
- return fmt.Errorf("username must have at least %d characters", LenNameMin)
+ if len(m.Name()) < UsernameLength {
+ return fmt.Errorf("username must have at least %d characters", UsernameLength)
}
// Validate user role.
@@ -867,3 +877,12 @@ func (m *User) SetAvatar(thumb, thumbSrc string) error {
return m.Updates(Values{"Thumb": m.Thumb, "ThumbSrc": m.ThumbSrc})
}
+
+// Login returns the login name and provider.
+func (m *User) Login() string {
+ if m.AuthProvider == "" {
+ return m.UserName
+ } else {
+ return fmt.Sprintf("%s@%s", m.UserName, m.AuthProvider)
+ }
+}
diff --git a/internal/entity/auth_user_add.go b/internal/entity/auth_user_add.go
index 50617078d..17abeb68f 100644
--- a/internal/entity/auth_user_add.go
+++ b/internal/entity/auth_user_add.go
@@ -13,8 +13,8 @@ import (
func AddUser(frm form.User) error {
user := NewUser().SetFormValues(frm)
- if len(frm.Password) < LenPasswordMin {
- return fmt.Errorf("password must have at least %d characters", LenPasswordMin)
+ if len(frm.Password) < PasswordLength {
+ return fmt.Errorf("password must have at least %d characters", PasswordLength)
}
if err := user.Validate(); err != nil {
diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go
index d5058dcb2..6f8bca592 100644
--- a/internal/entity/auth_user_test.go
+++ b/internal/entity/auth_user_test.go
@@ -333,6 +333,7 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, "alice@example.com", m.UserEmail)
assert.True(t, m.SuperAdmin)
assert.True(t, m.IsAdmin())
+ assert.True(t, m.IsSuperAdmin())
assert.False(t, m.IsVisitor())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
@@ -352,6 +353,7 @@ func TestFindUserByUID(t *testing.T) {
assert.Equal(t, "bob@example.com", m.UserEmail)
assert.False(t, m.SuperAdmin)
assert.True(t, m.IsAdmin())
+ assert.False(t, m.IsSuperAdmin())
assert.False(t, m.IsVisitor())
assert.True(t, m.CanLogin)
assert.NotEmpty(t, m.CreatedAt)
@@ -537,15 +539,6 @@ func TestUser_Validate(t *testing.T) {
assert.Error(t, u.Validate())
})
- t.Run("NameTooShort", func(t *testing.T) {
- u := &User{
- UserName: "va",
- DisplayName: "Validate",
- UserEmail: "validate@example.com",
- UserRole: acl.RoleAdmin.String(),
- }
- assert.Error(t, u.Validate())
- })
t.Run("NameNotUnique", func(t *testing.T) {
FirstOrCreateUser(&User{
UserName: "notunique1",
diff --git a/internal/entity/entity_const.go b/internal/entity/entity_const.go
index 85babd375..f53b595a7 100644
--- a/internal/entity/entity_const.go
+++ b/internal/entity/entity_const.go
@@ -52,6 +52,12 @@ const (
IsUnstacked int8 = -1
)
+// Authentication providers.
+const (
+ ProviderNone = ""
+ ProviderPassword = "password"
+)
+
// Sort options.
const (
SortOrderDefault = ""
diff --git a/internal/query/photo.go b/internal/query/photo.go
index ec6e25326..8d2305b07 100644
--- a/internal/query/photo.go
+++ b/internal/query/photo.go
@@ -1,9 +1,10 @@
package query
import (
+ "time"
+
"github.com/dustin/go-humanize/english"
"github.com/jinzhu/gorm"
- "time"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/mutex"
diff --git a/internal/server/routes_static.go b/internal/server/routes_static.go
index 678167a98..132044d61 100644
--- a/internal/server/routes_static.go
+++ b/internal/server/routes_static.go
@@ -4,18 +4,17 @@ import (
"net/http"
"path/filepath"
- "github.com/photoprism/photoprism/internal/i18n"
-
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
+ "github.com/photoprism/photoprism/internal/i18n"
)
// registerStaticRoutes configures serving static assets and templates.
func registerStaticRoutes(router *gin.Engine, conf *config.Config) {
// Redirects to the PWA for now, can be replaced by a template later.
router.GET(conf.BaseUri("/"), func(c *gin.Context) {
- c.Redirect(http.StatusTemporaryRedirect, conf.BaseUri("/library/login"))
+ c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
})
// Shows "Page Not found" error if no other handler is registered.