Auth: Improve account management page and config options #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-10-19 05:09:09 +02:00
parent caa14e621c
commit f94ff54cc1
68 changed files with 1257 additions and 964 deletions

View file

@ -97,7 +97,7 @@ install:
mkdir --mode=$(INSTALL_MODE) -p $(DESTDIR)
env TMPDIR="$(BUILD_PATH)" ./scripts/dist/install-tensorflow.sh $(DESTDIR)
rm -rf --preserve-root $(DESTDIR)/include
(cd $(DESTDIR) && mkdir -p bin sbin lib assets config config/examples)
(cd $(DESTDIR) && mkdir -p bin lib assets config config/examples)
./scripts/build.sh prod "$(DESTDIR)/bin/$(BINARY_NAME)"
rsync -r -l --safe-links --exclude-from=assets/.buildignore --chmod=a+r,u+rw ./assets/ $(DESTDIR)/assets
wget -O $(DESTDIR)/assets/static/img/wallpaper/welcome.jpg https://cdn.photoprism.app/wallpaper/welcome.jpg
@ -153,6 +153,9 @@ generate:
git checkout -- assets/locales/messages.pot;\
echo "Reverted unnecessary change in assets/locales/messages.pot.";\
fi
go-generate:
go generate ./pkg/... ./internal/...
go fmt ./pkg/... ./internal/...
clean-local-assets:
rm -rf $(BUILD_PATH)/assets/*
clean-local-cache:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -19,6 +19,7 @@ services:
- "40000:40000" # Go Debugger (host:container)
shm_size: "2gb"
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
- "traefik:keycloak.localssl.dev"
- "traefik:dummy-webdav.localssl.dev"
@ -27,7 +28,7 @@ services:
- "traefik.enable=true"
- "traefik.http.services.photoprism.loadbalancer.server.port=2342"
- "traefik.http.routers.photoprism.entrypoints=websecure"
- "traefik.http.routers.photoprism.rule=Host(`localssl.dev`, `app.localssl.dev`, `photoprism.localssl.dev`)"
- "traefik.http.routers.photoprism.rule=Host(`localssl.dev`, `app.localssl.dev`)"
- "traefik.http.routers.photoprism.tls.domains[0].main=localssl.dev"
- "traefik.http.routers.photoprism.tls.domains[0].sans=*.localssl.dev"
- "traefik.http.routers.photoprism.tls=true"
@ -148,6 +149,7 @@ services:
image: quay.io/keycloak/keycloak:19.0
command: "start-dev" # development mode, do not use this in production!
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
labels:
- "traefik.enable=true"

View file

@ -56,7 +56,7 @@ import * as offline from "@lcdp/offline-plugin/runtime";
config.progress(50);
config.load().finally(() => {
config.update().finally(() => {
// Initialize libs and framework.
config.progress(66);
const viewer = new Viewer();

View file

@ -41,15 +41,15 @@ const testConfig = {
manifestUri: "/manifest.json?0e41a7e5",
};
const config = window.__CONFIG__ ? window.__CONFIG__ : testConfig;
const c = window.__CONFIG__ ? window.__CONFIG__ : testConfig;
const Api = Axios.create({
baseURL: config.apiUri,
baseURL: c.apiUri,
headers: {
common: {
"X-Session-ID": window.localStorage.getItem("session_id"),
"X-Client-Uri": config.jsUri,
"X-Client-Version": config.version,
"X-Client-Uri": c.jsUri,
"X-Client-Version": c.version,
},
},
});
@ -75,17 +75,12 @@ Api.interceptors.response.use(
console.warn("WARNING: Server returned HTML instead of JSON - API not implemented?");
}
// Update preview token.
if (resp.headers && resp.headers["x-preview-token"]) {
const previewToken = resp.headers["x-preview-token"];
const downloadToken = resp.headers["x-download-token"]
? resp.headers["x-download-token"]
: "";
if (config.previewToken !== previewToken) {
config.previewToken = previewToken;
config.downloadToken = downloadToken;
Event.publish("config.updated", { config: { previewToken, downloadToken } });
}
// Update tokens if provided.
if (resp.headers && resp.headers["x-preview-token"] && resp.headers["x-download-token"]) {
Event.publish("config.tokens", {
previewToken: resp.headers["x-preview-token"],
downloadToken: resp.headers["x-download-token"],
});
}
return resp;

View file

@ -44,6 +44,7 @@ export default class Config {
this.storage_key = "config";
this.previewToken = "";
this.downloadToken = "";
this.updating = false;
this.$vuetify = null;
this.translations = translations;
@ -62,7 +63,10 @@ export default class Config {
this.staticUri = "/static";
this.apiUri = "/api/v1";
this.contentUri = this.apiUri;
this.values = {};
this.values = {
mode: "test",
name: "Test",
};
this.page = {
title: "PhotoPrism",
caption: "AI-Powered Photos App",
@ -106,6 +110,7 @@ export default class Config {
this.updateTokens();
Event.subscribe("config.updated", (ev, data) => this.setValues(data.config));
Event.subscribe("config.tokens", (ev, data) => this.setTokens(data));
Event.subscribe("count", (ev, data) => this.onCount(ev, data));
Event.subscribe("people", (ev, data) => this.onPeople(ev, data));
@ -125,16 +130,27 @@ export default class Config {
return this.update();
}
return Promise.resolve();
return Promise.resolve(this);
}
update() {
return Api.get("config")
if (this.updating !== false) {
return this.updating;
}
this.updating = Api.get("config")
.then(
(response) => this.setValues(response.data),
(resp) => {
return this.setValues(resp.data);
},
() => console.warn("config update failed")
)
.finally(() => Promise.resolve());
.finally(() => {
this.updating = false;
return Promise.resolve(this);
});
return this.updating;
}
setValues(values) {
@ -525,6 +541,16 @@ export default class Config {
return Languages().some((lang) => lang.value === this.values.settings.ui.language && lang.rtl);
}
setTokens(tokens) {
if (!tokens || !tokens.previewToken || !tokens.downloadToken) {
return;
}
this.previewToken = tokens.previewToken;
this.values.previewToken = tokens.previewToken;
this.downloadToken = tokens.downloadToken;
this.values.downloadToken = tokens.downloadToken;
}
updateTokens() {
if (this.values["previewToken"]) {
this.previewToken = this.values.previewToken;

View file

@ -29,6 +29,7 @@ import User from "model/user";
import Socket from "websocket.js";
const SessionHeader = "X-Session-ID";
const PublicID = "234200000000000000000000000000000000000000000000";
export default class Session {
/**
@ -297,7 +298,16 @@ export default class Session {
}
refresh() {
if (this.hasId() && !this.config.isPublic()) {
// Refresh session information.
if (this.config.isPublic()) {
// No authentication in public mode.
this.setId(PublicID);
return Api.get("session/" + this.getId()).then((resp) => {
this.setResp(resp);
return Promise.resolve();
});
} else if (this.hasId()) {
// Verify authentication.
return Api.get("session/" + this.getId())
.then((resp) => {
this.setResp(resp);
@ -309,6 +319,7 @@ export default class Session {
return Promise.reject();
});
} else {
// No authentication yet.
return Promise.resolve();
}
}

View file

@ -521,10 +521,7 @@
<v-list-tile v-show="auth && !isPublic && $config.feature('settings')" class="p-profile" @click.stop="onAccount">
<v-list-tile-avatar size="36">
<v-img :src="user.getAvatarURL()"
:alt="displayName" aspect-ratio="1"
class="primary-button elevation-0 clickable"
></v-img>
<img :src="userAvatarURL" :alt="displayName">
</v-list-tile-avatar>
<v-list-tile-content>
@ -663,8 +660,6 @@
<script>
import Album from "model/album";
import Event from "pubsub-js";
import Notify from "../common/notify";
import User from "../model/user";
export default {
name: "PNavigation",
@ -746,6 +741,9 @@ export default {
return this.$gettext("Unregistered");
},
userAvatarURL() {
return this.$session.getUser().getAvatarURL('tile_50');
},
accountInfo() {
const user = this.$session.getUser();
if (user) {

View file

@ -44,30 +44,10 @@ Additional information can be found in our Developer Guide:
@import url("pages.css");
@import url("help.css");
@import url("auth.css");
@import url("layout.css");
@import url("rtl.css");
@media (min-width: 1750px) {
.flex.xlg2 {
flex-basis: 16.666666666666664%;
flex-grow: 0;
max-width: 16.666666666666664%;
}
}
@media (min-width: 2400px) {
.flex.xxl1 {
flex-basis: 8.333333333333332%;
flex-grow: 0;
max-width: 8.333333333333332%;
}
}
@media (min-width: 2550px) {
.flex.xxxl1 {
flex-basis: 8.333333333333332%;
flex-grow: 0;
max-width: 8.333333333333332%;
}
}
/* Core Element Styles */
html,
body {
@ -82,51 +62,14 @@ body {
padding: 0;
}
footer {
clear: both;
padding: 1rem 2rem;
}
#photoprism .footer {
margin: 0;
padding: 6px 24px 12px;
}
#photoprism .footer div > span,
#photoprism .footer div > a {
display: block;
}
@media only screen and (max-width: 599px) {
#photoprism .footer div > span,
#photoprism .footer div > a {
display: inline-block;
}
#photoprism .footer .text-link {
float: left;
}
#photoprism .footer .body-link {
float: right;
}
}
#photoprism .footer .footer-actions {
padding: 0;
margin: 0;
}
#photoprism .p-about-footer .body-1 {
line-height: 1.8em;
}
main {
padding: 0;
margin: 0;
z-index: 1;
}
/* Page Overlay */
#busy-overlay {
display: none;
position: fixed;
@ -138,6 +81,8 @@ main {
background-color: rgba(0, 0, 0, 0.2);
}
/* General App Styles */
#photoprism div.fullscreen {
position: fixed;
top: 0;
@ -221,30 +166,13 @@ main {
height: 1.1rem;
}
/* Line Height */
.lh-15 {
line-height: 1.5rem !important;
ol, ul {
padding-left: 24px;
padding-right: 24px;
}
.lh-16 {
line-height: 1.6rem !important;
}
.lh-17 {
line-height: 1.7rem !important;
}
.lh-18 {
line-height: 1.8rem !important;
}
.lh-19 {
line-height: 1.9rem !important;
}
.lh-20 {
line-height: 2.0rem !important;
#photoprism .search-results.list-view .result {
padding: 1px 0 1px 8px !important;
}
/* Image Orientation */
@ -284,139 +212,3 @@ main {
.orientation-8 {
transform: rotate(270deg);
}
/* RTL alignments */
#photoprism.is-rtl .text-xs-left,
#photoprism.is-rtl .text-sm-left {
text-align: right !important;
}
#photoprism.is-rtl .text-xs-right,
#photoprism.is-rtl .text-sm-right {
text-align: left !important;
}
#photoprism.is-rtl .v-text-field .v-input__prepend-inner {
margin-left: auto;
padding-left: 4px;
padding-right: 0;
}
#photoprism.is-rtl .v-toolbar.page-toolbar .v-text-field .v-input__slot {
padding-left: 12px;
padding-right: 0;
}
#photoprism.is-rtl .card-details button {
text-align: right;
}
#photoprism.is-rtl .p-flex-menuitem .nav-count {
margin-left: 48px;
margin-right: 8px;
}
#photoprism.is-rtl .v-list__group__header .p-flex-menuitem .nav-count {
margin-left: 0;
}
#photoprism.is-rtl .v-toolbar__content > :last-child.v-btn--icon,
#photoprism.is-rtl .v-toolbar__extension > :last-child.v-btn--icon {
margin-left: -6px;
margin-right: 6px;
}
ol, ul {
padding-left: 24px;
padding-right: 24px;
}
/* Rounded Elements */
.v-progress-linear,
.v-progress-linear .v-progress-linear__bar__determinate,
.v-progress-linear .v-progress-linear__bar__indeterminate--active {
border-radius: 3px;
}
/* Result Border Radius */
.v-menu__content,
.v-btn.v-btn--depressed:not(.v-btn--round):not(.v-btn--icon),
.v-text-field.v-text-field--box > .v-input__control > .v-input__slot,
.v-text-field.v-text-field--solo > .v-input__control > .v-input__slot,
#photoprism .v-dialog .v-responsive.v-image,
#photoprism .cards-view .result,
#photoprism .mosaic-view .result.image,
#photoprism .list-view .result .image {
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,
#photoprism div.v-dialog > form > div.v-card,
#photoprism div.v-dialog > form > div.v-card > .v-card__text .v-expansion-panel__container {
border-radius: 6px;
}
.ra-3,
.ra-3 > a,
.rounded-3,
.rounded-3 > a {
border-radius: 3px !important;
}
.ra-4,
.ra-4 > a,
.rounded-4,
.rounded-4 > a {
border-radius: 4px !important;
}
.ra-5,
.ra-5 > a,
.rounded-5,
.rounded-5 > a {
border-radius: 5px !important;
}
.ra-6,
.ra-6 > a,
.rounded-6,
.rounded-6 > a {
border-radius: 6px !important;
}
.ra-8,
.ra-8 > a,
.rounded-8,
.rounded-8 > a {
border-radius: 8px !important;
}
.ra-10,
.ra-10 > a,
.rounded-10,
.rounded-10 > a {
border-radius: 10px !important;
}
#photoprism div.v-dialog.v-dialog--fullscreen > div.v-card {
border-radius: 0;
}
.v-autocomplete__content.v-menu__content,
.v-autocomplete__content.v-menu__content .v-card {
border-radius: 0 0 4px 4px;
}
.v-snack.v-snack--bottom .v-snack__wrapper {
border-radius: 5px 5px 0 0;
}
#photoprism .search-results.list-view .result {
padding: 1px 0 1px 8px !important;
}

View file

@ -88,8 +88,9 @@
position: relative;
text-align: center;
vertical-align: middle;
width: 100%;
max-width: 150px;
max-height: 150px;
max-width: 100%;
max-height: 100%;
width: 128px;
height: 128px;
overflow: hidden;
}

View file

@ -1,3 +1,5 @@
/* Font Settings */
body {
color: #333333;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
@ -26,4 +28,30 @@ body .application {
button.v-btn.compact {
font-size: 14px;
height: 34px;
}
}
/* Line Height */
.lh-15 {
line-height: 1.5rem !important;
}
.lh-16 {
line-height: 1.6rem !important;
}
.lh-17 {
line-height: 1.7rem !important;
}
.lh-18 {
line-height: 1.8rem !important;
}
.lh-19 {
line-height: 1.9rem !important;
}
.lh-20 {
line-height: 2.0rem !important;
}

168
frontend/src/css/layout.css Normal file
View file

@ -0,0 +1,168 @@
/* Layout Breakpoints */
@media (min-width: 1750px) {
.flex.xlg2 {
flex-basis: 16.666666666666664%;
flex-grow: 0;
max-width: 16.666666666666664%;
}
}
@media (min-width: 2400px) {
.flex.xxl1 {
flex-basis: 8.333333333333332%;
flex-grow: 0;
max-width: 8.333333333333332%;
}
}
@media (min-width: 2550px) {
.flex.xxxl1 {
flex-basis: 8.333333333333332%;
flex-grow: 0;
max-width: 8.333333333333332%;
}
}
/* Layout Widths */
.width-sm, .width-md, .width-lg {
margin-left: auto;
margin-right: auto;
}
.width-sm {
max-width: 600px;
}
.width-md {
max-width: 960px;
}
.width-lg {
max-width: 1264px;
}
/* Rounded Elements */
.v-progress-linear,
.v-progress-linear .v-progress-linear__bar__determinate,
.v-progress-linear .v-progress-linear__bar__indeterminate--active {
border-radius: 3px;
}
.v-menu__content,
.v-btn.v-btn--depressed:not(.v-btn--round):not(.v-btn--icon),
.v-text-field.v-text-field--box > .v-input__control > .v-input__slot,
.v-text-field.v-text-field--solo > .v-input__control > .v-input__slot,
#photoprism .v-dialog .v-responsive.v-image,
#photoprism .cards-view .result,
#photoprism .mosaic-view .result.image,
#photoprism .list-view .result .image {
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,
#photoprism div.v-dialog > form > div.v-card,
#photoprism div.v-dialog > form > div.v-card > .v-card__text .v-expansion-panel__container {
border-radius: 6px;
}
.ra-3,
.ra-3 > a,
.rounded-3,
.rounded-3 > a {
border-radius: 3px !important;
}
.ra-4,
.ra-4 > a,
.rounded-4,
.rounded-4 > a {
border-radius: 4px !important;
}
.ra-5,
.ra-5 > a,
.rounded-5,
.rounded-5 > a {
border-radius: 5px !important;
}
.ra-6,
.ra-6 > a,
.rounded-6,
.rounded-6 > a {
border-radius: 6px !important;
}
.ra-8,
.ra-8 > a,
.rounded-8,
.rounded-8 > a {
border-radius: 8px !important;
}
.ra-10,
.ra-10 > a,
.rounded-10,
.rounded-10 > a {
border-radius: 10px !important;
}
#photoprism div.v-dialog.v-dialog--fullscreen > div.v-card {
border-radius: 0;
}
.v-autocomplete__content.v-menu__content,
.v-autocomplete__content.v-menu__content .v-card {
border-radius: 0 0 4px 4px;
}
.v-snack.v-snack--bottom .v-snack__wrapper {
border-radius: 5px 5px 0 0;
}
/* Page Footer */
footer {
clear: both;
padding: 1rem 2rem;
}
#photoprism .footer {
margin: 0;
padding: 6px 24px 12px;
}
#photoprism .footer div > span,
#photoprism .footer div > a {
display: block;
}
@media only screen and (max-width: 599px) {
#photoprism .footer div > span,
#photoprism .footer div > a {
display: inline-block;
}
#photoprism .footer .text-link {
float: left;
}
#photoprism .footer .body-link {
float: right;
}
}
#photoprism .footer .footer-actions {
padding: 0;
margin: 0;
}
#photoprism .p-about-footer .body-1 {
line-height: 1.8em;
}

41
frontend/src/css/rtl.css Normal file
View file

@ -0,0 +1,41 @@
/* Right-to-left (RTL) Language Styles */
#photoprism.is-rtl .text-xs-left,
#photoprism.is-rtl .text-sm-left {
text-align: right !important;
}
#photoprism.is-rtl .text-xs-right,
#photoprism.is-rtl .text-sm-right {
text-align: left !important;
}
#photoprism.is-rtl .v-text-field .v-input__prepend-inner {
margin-left: auto;
padding-left: 4px;
padding-right: 0;
}
#photoprism.is-rtl .v-toolbar.page-toolbar .v-text-field .v-input__slot {
padding-left: 12px;
padding-right: 0;
}
#photoprism.is-rtl .card-details button {
text-align: right;
}
#photoprism.is-rtl .p-flex-menuitem .nav-count {
margin-left: 48px;
margin-right: 8px;
}
#photoprism.is-rtl .v-list__group__header .p-flex-menuitem .nav-count {
margin-left: 0;
}
#photoprism.is-rtl .v-toolbar__content > :last-child.v-btn--icon,
#photoprism.is-rtl .v-toolbar__extension > :last-child.v-btn--icon {
margin-left: -6px;
margin-right: 6px;
}

View file

@ -146,7 +146,7 @@ export default {
},
cancel() {
if (this.busy) {
Notify.info(this.$gettext("Uploading…"));
Notify.info(this.$gettext("Uploading photos…"));
return;
}
@ -154,7 +154,7 @@ export default {
},
confirm() {
if (this.busy) {
Notify.info(this.$gettext("Uploading…"));
Notify.info(this.$gettext("Uploading photos…"));
return;
}
@ -201,7 +201,7 @@ export default {
let userUid = this.$session.getUserUID();
Notify.info(this.$gettext("Uploading…"));
Notify.info(this.$gettext("Uploading photos…"));
let addToAlbums = [];

View file

@ -31,10 +31,10 @@ msgstr ""
msgid "%{n} people found"
msgstr ""
#: src/page/album/photos.vue:222
#: src/page/album/photos.vue:375
#: src/page/photos.vue:308
#: src/page/photos.vue:474
#: src/page/album/photos.vue:240
#: src/page/album/photos.vue:393
#: src/page/photos.vue:325
#: src/page/photos.vue:490
msgid "%{n} pictures found"
msgstr ""
@ -67,11 +67,11 @@ msgid "Abyss"
msgstr ""
#: src/component/navigation.vue:3
#: src/component/navigation.vue:92
#: src/component/navigation.vue:1909
#: src/component/navigation.vue:93
#: src/component/navigation.vue:1907
#: src/dialog/share/upload.vue:108
#: src/model/service.js:98
#: src/model/user.js:142
#: src/model/user.js:144
#: src/page/settings.vue:83
#: src/page/settings/general.vue:470
msgid "Account"
@ -125,10 +125,6 @@ msgstr ""
msgid "Added"
msgstr ""
#: src/options/options.js:415
msgid "Admin"
msgstr ""
#: src/page/settings.vue:59
msgid "Advanced"
msgstr ""
@ -261,7 +257,7 @@ msgstr ""
msgid "Altitude (m)"
msgstr ""
#: src/common/api.js:104
#: src/common/api.js:102
msgid "An error occurred - are you offline?"
msgstr ""
@ -370,10 +366,14 @@ msgid "Being 100% self-funded and independent, we can promise you that we will n
msgstr ""
#: src/page/people/recognized.vue:434
#: src/page/settings/account.vue:607
#: src/page/settings/account.vue:279
msgid "Bio"
msgstr ""
#: src/page/settings/account.vue:67
msgid "Birth Date"
msgstr ""
#: src/options/options.js:395
msgid "Black"
msgstr ""
@ -428,8 +428,8 @@ msgstr ""
msgid "Camera Serial"
msgstr ""
#: src/page/album/photos.vue:228
#: src/page/photos.vue:314
#: src/page/album/photos.vue:246
#: src/page/photos.vue:331
msgid "Can't load more, limit reached"
msgstr ""
@ -480,7 +480,7 @@ msgid "Category"
msgstr ""
#: src/dialog/account/password.vue:7
#: src/page/settings/account.vue:38
#: src/page/settings/account.vue:51
msgid "Change Password"
msgstr ""
@ -496,6 +496,14 @@ msgstr ""
msgid "Change private flag"
msgstr ""
#: src/page/settings/account.vue:106
#: src/page/settings/account.vue:121
#: src/page/settings/advanced.vue:42
#: src/page/settings/general.vue:105
#: src/page/settings/library.vue:50
msgid "Changes successfully saved"
msgstr ""
#: src/dialog/photo/edit/info.vue:191
msgid "Checked"
msgstr ""
@ -549,7 +557,7 @@ msgid "Connect"
msgstr ""
#: src/dialog/webdav.vue:4
#: src/page/settings/account.vue:44
#: src/page/settings/account.vue:57
#: src/page/settings/services.vue:48
msgid "Connect via WebDAV"
msgstr ""
@ -558,7 +566,7 @@ msgstr ""
msgid "Connected"
msgstr ""
#: src/page/settings/account.vue:78
#: src/page/settings/account.vue:90
msgid "Contact Details"
msgstr ""
@ -578,10 +586,6 @@ msgstr ""
msgid "Contains one picture."
msgstr ""
#: src/options/options.js:420
msgid "Contributor"
msgstr ""
#: src/page/settings/library.vue:111
msgid "Convert to JPEG"
msgstr ""
@ -605,7 +609,7 @@ msgstr ""
#: src/component/photo/toolbar.vue:178
#: src/dialog/photo/edit/details.vue:248
#: src/page/settings/account.vue:495
#: src/page/settings/account.vue:578
msgid "Country"
msgstr ""
@ -646,6 +650,7 @@ msgid "Daily"
msgstr ""
#: src/dialog/photo/edit/details.vue:116
#: src/page/settings/account.vue:444
msgid "Day"
msgstr ""
@ -746,7 +751,7 @@ msgstr ""
msgid "Discover"
msgstr ""
#: src/page/settings/account.vue:134
#: src/page/settings/account.vue:191
msgid "Display Name"
msgstr ""
@ -885,8 +890,7 @@ msgstr ""
msgid "Edited"
msgstr ""
#: src/page/settings/account.vue:159
#: src/page/settings/account.vue:415
#: src/page/settings/account.vue:219
msgid "Email"
msgstr ""
@ -970,8 +974,8 @@ msgstr ""
msgid "Failed updating link"
msgstr ""
#: src/options/options.js:417
msgid "Family"
#: src/page/settings/account.vue:164
msgid "Family Name"
msgstr ""
#: src/options/options.js:249
@ -992,7 +996,7 @@ msgstr ""
msgid "Feature Request"
msgstr ""
#: src/page/settings/account.vue:580
#: src/page/settings/account.vue:669
msgid "Feed"
msgstr ""
@ -1005,6 +1009,10 @@ msgstr ""
msgid "Feel free to contact us at hello@photoprism.app if you have any questions."
msgstr ""
#: src/options/options.js:416
msgid "Female"
msgstr ""
#: src/model/file.js:291
msgid "File"
msgstr ""
@ -1070,10 +1078,6 @@ msgstr ""
msgid "Frames"
msgstr ""
#: src/options/options.js:418
msgid "Friend"
msgstr ""
#: src/component/photo-viewer.vue:172
msgid "Fullscreen"
msgstr ""
@ -1082,6 +1086,10 @@ msgstr ""
msgid "Gemstone"
msgstr ""
#: src/page/settings/account.vue:80
msgid "Gender"
msgstr ""
#: src/page/settings.vue:35
msgid "General"
msgstr ""
@ -1090,6 +1098,10 @@ msgstr ""
msgid "Getting Support"
msgstr ""
#: src/page/settings/account.vue:138
msgid "Given Name"
msgstr ""
#: src/options/options.js:385
msgid "Gold"
msgstr ""
@ -1112,10 +1124,6 @@ msgstr ""
msgid "Group by similarity"
msgstr ""
#: src/options/options.js:421
msgid "Guest"
msgstr ""
#: src/dialog/photo/edit/files.vue:59
#: src/dialog/photo/edit/files.vue:56
msgid "Hash"
@ -1266,6 +1274,20 @@ msgstr ""
msgid "Interval"
msgstr ""
#: src/page/settings/account.vue:89
#: src/page/settings/account.vue:115
#: src/page/settings/account.vue:141
#: src/page/settings/account.vue:167
#: src/page/settings/account.vue:222
#: src/page/settings/account.vue:278
#: src/page/settings/account.vue:562
#: src/page/settings/account.vue:589
#: src/page/settings/account.vue:616
#: src/page/settings/account.vue:644
#: src/page/settings/account.vue:672
msgid "Invalid"
msgstr ""
#: src/dialog/photo/edit/details.vue:112
msgid "Invalid date"
msgstr ""
@ -1427,7 +1449,7 @@ msgstr ""
#: src/component/photo/cards.vue:512
#: src/component/photo/list.vue:138
#: src/dialog/album/edit.vue:125
#: src/page/settings/account.vue:477
#: src/page/settings/account.vue:559
msgid "Location"
msgstr ""
@ -1438,14 +1460,14 @@ msgstr ""
#: src/component/navigation.vue:454
#: src/component/navigation.vue:1686
#: src/component/navigation.vue:1961
#: src/component/navigation.vue:1959
msgid "Login"
msgstr ""
#: src/component/navigation.vue:501
#: src/component/navigation.vue:1798
#: src/component/navigation.vue:1829
#: src/component/navigation.vue:1880
#: src/component/navigation.vue:1796
#: src/component/navigation.vue:1827
#: src/component/navigation.vue:1878
msgid "Logout"
msgstr ""
@ -1473,6 +1495,10 @@ msgstr ""
msgid "Main Color"
msgstr ""
#: src/options/options.js:415
msgid "Male"
msgstr ""
#: src/dialog/photo/edit/labels.vue:35
msgid "manual"
msgstr ""
@ -1519,6 +1545,7 @@ msgstr ""
#: src/component/photo/toolbar.vue:291
#: src/dialog/photo/edit/details.vue:142
#: src/page/settings/account.vue:467
msgid "Month"
msgstr ""
@ -1526,8 +1553,8 @@ msgstr ""
msgid "Moonlight"
msgstr ""
#: src/page/album/photos.vue:378
#: src/page/photos.vue:477
#: src/page/album/photos.vue:396
#: src/page/photos.vue:493
msgid "More than %{n} pictures found"
msgstr ""
@ -1650,9 +1677,9 @@ msgstr ""
#: src/component/photo/list.vue:8
#: src/component/photo/mosaic.vue:8
#: src/component/photo/mosaic.vue:6
#: src/page/album/photos.vue:371
#: src/page/album/photos.vue:389
#: src/page/library/browse.vue:34
#: src/page/photos.vue:470
#: src/page/photos.vue:486
#: src/page/places.vue:217
#: src/page/places.vue:279
msgid "No pictures found"
@ -1780,8 +1807,8 @@ msgstr ""
msgid "One person found"
msgstr ""
#: src/page/album/photos.vue:373
#: src/page/photos.vue:472
#: src/page/album/photos.vue:391
#: src/page/photos.vue:488
msgid "One picture found"
msgstr ""
@ -1801,10 +1828,6 @@ msgstr ""
msgid "Orange"
msgstr ""
#: src/page/settings/account.vue:340
msgid "Organization"
msgstr ""
#: src/dialog/photo/edit/files.vue:155
#: src/dialog/photo/edit/files.vue:152
msgid "Orientation"
@ -1829,6 +1852,7 @@ msgid "Originals"
msgstr ""
#: src/options/options.js:404
#: src/options/options.js:417
msgid "Other"
msgstr ""
@ -1884,7 +1908,7 @@ msgstr ""
msgid "Permanently remove files to free up storage."
msgstr ""
#: src/page/settings/account.vue:528
#: src/page/settings/account.vue:613
msgid "Phone"
msgstr ""
@ -1995,10 +2019,6 @@ msgstr ""
msgid "Product Feedback"
msgstr ""
#: src/page/settings/account.vue:554
msgid "Profile"
msgstr ""
#: src/dialog/photo/edit/files.vue:143
#: src/dialog/photo/edit/files.vue:140
msgid "Projection"
@ -2080,7 +2100,7 @@ msgid "Red"
msgstr ""
#: src/component/album/toolbar.vue:64
#: src/component/navigation.vue:1894
#: src/component/navigation.vue:1892
#: src/component/photo/toolbar.vue:54
#: src/dialog/reload.vue:15
#: src/page/albums.vue:126
@ -2092,7 +2112,7 @@ msgstr ""
msgid "Reload"
msgstr ""
#: src/component/navigation.vue:123
#: src/component/navigation.vue:124
#: src/dialog/reload.vue:26
#: src/page/settings/general.vue:101
#: src/page/settings/library.vue:46
@ -2127,6 +2147,7 @@ msgstr ""
#: src/page/about/feedback.vue:130
#: src/page/about/feedback.vue:150
#: src/page/about/feedback.vue:192
#: src/page/settings/account.vue:194
msgid "Required"
msgstr ""
@ -2196,7 +2217,7 @@ msgstr ""
msgid "Secret"
msgstr ""
#: src/page/settings/account.vue:31
#: src/page/settings/account.vue:44
msgid "Security and Access"
msgstr ""
@ -2250,18 +2271,10 @@ msgstr ""
#: src/component/navigation.vue:18
#: src/component/navigation.vue:4
#: src/component/navigation.vue:1520
#: src/component/navigation.vue:1927
#: src/component/navigation.vue:1925
msgid "Settings"
msgstr ""
#: src/page/settings/account.vue:68
#: src/page/settings/account.vue:83
#: src/page/settings/advanced.vue:42
#: src/page/settings/general.vue:105
#: src/page/settings/library.vue:50
msgid "Settings saved"
msgstr ""
#: src/dialog/share/upload.vue:32
msgid "Setup"
msgstr ""
@ -2504,7 +2517,7 @@ msgstr ""
#: src/component/photo/list.vue:135
#: src/dialog/photo/edit/details.vue:95
#: src/dialog/photo/edit/info.vue:45
#: src/page/settings/account.vue:389
#: src/page/settings/account.vue:112
msgid "Title"
msgstr ""
@ -2553,10 +2566,6 @@ msgstr ""
msgid "Type"
msgstr ""
#: src/options/options.js:423
msgid "Unauthorized"
msgstr ""
#: src/dialog/photo/edit/people.vue:146
msgid "Undo"
msgstr ""
@ -2579,16 +2588,15 @@ msgstr ""
#: src/options/options.js:51
#: src/options/options.js:65
#: src/options/options.js:77
#: src/options/options.js:424
#: src/page/library/errors.vue:168
#: src/page/library/errors.vue:175
#: src/page/library/logs.vue:18
msgid "Unknown"
msgstr ""
#: src/component/navigation.vue:84
#: src/model/user.js:128
#: src/page/settings/account.vue:38
#: src/component/navigation.vue:82
#: src/model/user.js:130
#: src/page/settings/account.vue:35
msgid "Unregistered"
msgstr ""
@ -2620,6 +2628,10 @@ msgstr ""
msgid "Updating moments"
msgstr ""
#: src/page/settings/account.vue:116
msgid "Updating picture…"
msgstr ""
#: src/page/library/index.vue:153
msgid "Updating previews"
msgstr ""
@ -2629,7 +2641,7 @@ msgid "Updating stacks"
msgstr ""
#: src/component/album/toolbar.vue:192
#: src/component/navigation.vue:1945
#: src/component/navigation.vue:1943
#: src/component/photo/toolbar.vue:122
#: src/dialog/share/upload.vue:35
#: src/dialog/upload.vue:8
@ -2669,8 +2681,7 @@ msgstr ""
#: src/dialog/upload.vue:60
#: src/dialog/upload.vue:68
#: src/dialog/upload.vue:115
#: src/page/settings/account.vue:78
msgid "Uploading…"
msgid "Uploading photos…"
msgstr ""
#: src/dialog/upload.vue:46
@ -2678,7 +2689,6 @@ msgid "Uploads that may contain such images will be rejected automatically."
msgstr ""
#: src/dialog/share.vue:153
#: src/page/settings/account.vue:365
msgid "URL"
msgstr ""
@ -2686,8 +2696,7 @@ msgstr ""
msgid "Use Presets"
msgstr ""
#: src/model/user.js:209
#: src/options/options.js:416
#: src/model/user.js:211
msgid "User"
msgstr ""
@ -2703,7 +2712,6 @@ msgstr ""
#: src/dialog/service/add.vue:103
#: src/dialog/service/edit.vue:467
#: src/dialog/share.vue:23
#: src/page/settings/account.vue:110
msgid "Username"
msgstr ""
@ -2740,18 +2748,10 @@ msgstr ""
msgid "View"
msgstr ""
#: src/options/options.js:419
msgid "Viewer"
msgstr ""
#: src/page/about/about.vue:47
msgid "Visit docs.photoprism.app/user-guide to learn how to sync, organize, and share your pictures."
msgstr ""
#: src/options/options.js:422
msgid "Visitor"
msgstr ""
#: src/page/about/feedback.vue:13
msgid "We appreciate your feedback!"
msgstr ""
@ -2783,16 +2783,17 @@ msgstr ""
msgid "WebDAV Upload"
msgstr ""
#: src/page/settings/account.vue:641
msgid "Website"
msgstr ""
#: src/options/options.js:393
msgid "White"
msgstr ""
#: src/page/settings/account.vue:54
msgid "Work Details"
msgstr ""
#: src/component/photo/toolbar.vue:268
#: src/dialog/photo/edit/details.vue:168
#: src/page/settings/account.vue:490
msgid "Year"
msgstr ""

View file

@ -67,7 +67,6 @@ export class User extends RestModel {
UpdatedAt: "",
},
Details: {
IdURL: "",
SubjUID: "",
SubjSrc: "",
PlaceID: "",
@ -76,13 +75,15 @@ export class User extends RestModel {
BirthYear: -1,
BirthMonth: -1,
BirthDay: -1,
NamePrefix: "",
NameTitle: "",
GivenName: "",
MiddleName: "",
FamilyName: "",
NameSuffix: "",
NickName: "",
NameSrc: "",
Gender: "",
About: "",
Bio: "",
Location: "",
Country: "",
@ -91,11 +92,12 @@ export class User extends RestModel {
ProfileURL: "",
FeedURL: "",
AvatarURL: "",
OrgName: "",
OrgTitle: "",
OrgName: "",
OrgEmail: "",
OrgPhone: "",
OrgURL: "",
IdURL: "",
CreatedAt: "",
UpdatedAt: "",
},
@ -153,22 +155,22 @@ export class User extends RestModel {
}
getAvatarURL(size) {
if (!this.Thumb) {
return `${config.contentUri}/svg/user`;
}
if (!size) {
size = "tile_500";
}
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
if (this.Thumb) {
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
} else {
return `${config.staticUri}/img/avatar/${size}.jpg`;
}
}
uploadAvatar(files) {
if (this.busy) {
return;
return Promise.reject(this);
} else if (!files || files.length !== 1) {
return;
return Promise.reject(this);
}
let file = files[0];

View file

@ -411,15 +411,8 @@ export const ThumbFilters = () => [
{ value: "linear", text: $gettext("Linear: Very Smooth, Best Performance") },
];
export const UserRoles = () => [
{ value: "admin", text: $gettext("Admin") },
{ value: "user", text: $gettext("User") },
{ value: "family", text: $gettext("Family") },
{ value: "friend", text: $gettext("Friend") },
{ value: "viewer", text: $gettext("Viewer") },
{ value: "contributor", text: $gettext("Contributor") },
{ value: "guest", text: $gettext("Guest") },
{ value: "visitor", text: $gettext("Visitor") },
{ value: "unauthorized", text: $gettext("Unauthorized") },
{ value: "", text: $gettext("Unknown") },
export const Gender = () => [
{ value: "male", text: $gettext("Male") },
{ value: "female", text: $gettext("Female") },
{ value: "other", text: $gettext("Other") },
];

View file

@ -1,56 +1,96 @@
<template>
<div class="p-tab p-settings-account">
<v-form ref="form" lazy-validation
dense class="p-form-account" accept-charset="UTF-8"
<v-form ref="form" v-model="valid" lazy-validation dense class="p-form-account pb-4 width-lg" accept-charset="UTF-8"
@submit.prevent="onChange">
<input ref="upload" type="file" class="d-none input-upload" @change.stop="onUploadAvatar()">
<v-card flat tile class="mt-2 px-1 application">
<v-card-actions>
<v-layout row wrap align-top>
<v-flex
class="p-photo pa-3 text-xs-center"
xs4 sm3 md2 xl1 fill-height
>
<div class="user-avatar" @click.exact="onChangeAvatar()">
<v-img :src="user.getAvatarURL()"
:alt="displayName" aspect-ratio="1"
class="primary-button elevation-0 clickable"
></v-img>
</div>
</v-flex>
<v-flex xs8 sm9 md10 xl11 fill-height class="pa-0">
<v-flex xs8 sm9 md10 fill-height class="pa-0">
<v-layout wrap align-top>
<v-flex xs12 md3 class="pa-2">
<v-flex md2 class="pa-2 hidden-sm-and-down">
<v-select v-model="user.Details.Gender"
:label="$gettext('Gender')"
hide-details box flat
item-text="text"
item-value="value"
color="secondary-dark"
:items="options.Gender()"
class="input-gender"
:rules="[v => validLength(v, 0, 16) || $gettext('Invalid')]"
@change="onChange">
</v-select>
</v-flex>
<v-flex md2 class="pa-2 hidden-sm-and-down">
<v-text-field
v-model="user.Name"
hide-details required box flat readonly
v-model="user.Details.NameTitle"
hide-details required box flat
:disabled="busy"
maxlength="32"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Username')"
class="input-name"
:label="$gettext('Title')"
class="input-name-title"
color="secondary-dark"
:rules="[v => validLength(v, 0, 32) || $gettext('Invalid')]"
@change="onChangeName"
></v-text-field>
</v-flex>
<v-flex xs12 md9 class="pa-2">
<v-flex md4 class="pa-2 hidden-sm-and-down">
<v-text-field
v-model="user.Details.GivenName"
hide-details required box flat
:disabled="busy"
maxlength="64"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Given Name')"
class="input-given-name"
color="secondary-dark"
:rules="[v => validLength(v, 0, 64) || $gettext('Invalid')]"
@change="onChangeName"
></v-text-field>
</v-flex>
<v-flex md4 class="pa-2 hidden-sm-and-down">
<v-text-field
v-model="user.Details.FamilyName"
hide-details required box flat
:disabled="busy"
maxlength="64"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Family Name')"
class="input-family-name"
color="secondary-dark"
:rules="[v => validLength(v, 0, 64) || $gettext('Invalid')]"
@change="onChangeName"
></v-text-field>
</v-flex>
<v-flex xs12 md4 class="pa-2">
<v-text-field
v-model="user.DisplayName"
hide-details required box flat
:disabled="busy"
maxlength="200"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Display Name')"
class="input-display-name"
color="secondary-dark"
:rules="[v => validLength(v, 1, 200) || $gettext('Required')]"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<v-flex xs12 md8 class="pa-2">
<v-text-field
v-model="user.Email"
hide-details required box flat
type="email"
maxlength="250"
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
@ -58,11 +98,30 @@
:label="$gettext('Email')"
class="input-email"
color="secondary-dark"
:rules="[v => !!v && validEmail(v) || $gettext('Invalid')]"
@change="onChange"
></v-text-field>
</v-flex>
</v-layout>
</v-flex>
<v-flex
class="pa-2 text-xs-center"
xs4 sm3 md2 align-self-center
>
<v-avatar :size="$vuetify.breakpoint.xsOnly ? 100 : 128" :class="{'clickable': !busy}" @click.stop.prevent="onChangeAvatar()">
<img :src="$vuetify.breakpoint.xsOnly ? user.getAvatarURL('tile_100') : user.getAvatarURL('tile_224')" :alt="displayName">
</v-avatar>
</v-flex>
<v-flex xs12 class="pa-2">
<v-textarea v-model="user.Details.Bio" auto-grow flat box hide-details
rows="2" class="input-bio" color="secondary-dark"
autocorrect="off" autocapitalize="none" browser-autocomplete="off"
:disabled="busy"
maxlength="500"
:rules="[v => validLength(v, 0, 500) || $gettext('Invalid')]"
:label="$gettext('Bio')"
@change="onChange"></v-textarea>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
@ -94,69 +153,49 @@
<v-card flat tile class="mt-0 px-1 application">
<v-card-title primary-title class="pb-1">
<h3 class="body-2 mb-0">
<translate>Work Details</translate>
<translate>Birth Date</translate>
</h3>
</v-card-title>
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.OrgName"
hide-details required box flat
<v-flex xs3 class="pa-2">
<v-autocomplete
v-model="user.Details.BirthDay"
:disabled="busy"
:label="$gettext('Day')"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Organization')"
class="input-org-name"
hide-no-data hide-details box flat
color="secondary-dark"
@change="onChange"
></v-text-field>
:items="options.Days()"
class="input-birth-day"
@change="onChange">
</v-autocomplete>
</v-flex>
<v-flex xs3 class="pa-2">
<v-autocomplete
v-model="user.Details.BirthMonth"
:disabled="busy"
:label="$gettext('Month')"
browser-autocomplete="off"
hide-no-data hide-details box flat
color="secondary-dark"
:items="options.MonthsShort()"
class="input-birth-month"
@change="onChange">
</v-autocomplete>
</v-flex>
<v-flex xs6 class="pa-2">
<v-text-field
v-model="user.Details.OrgURL"
hide-details required box flat
<v-autocomplete
v-model="user.Details.BirthYear"
:disabled="busy"
type="url"
:label="$gettext('Year')"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('URL')"
class="input-org-url"
hide-no-data hide-details box flat
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs6 class="pa-2">
<v-text-field
v-model="user.Details.OrgTitle"
hide-details required box flat
:disabled="busy"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Title')"
class="input-position"
color="secondary-dark"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.OrgEmail"
hide-details required box flat
:disabled="busy"
type="email"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Email')"
class="input-org-email"
color="secondary-dark"
@change="onChange"
></v-text-field>
:items="options.Years()"
class="input-birth-year"
@change="onChange">
</v-autocomplete>
</v-flex>
</v-layout>
</v-card-actions>
@ -174,57 +213,64 @@
v-model="user.Details.Location"
hide-details required box flat
:disabled="busy"
maxlength="500"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Location')"
class="input-location"
color="secondary-dark"
:rules="[v => validLength(v, 0, 500) || $gettext('Invalid')]"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-flex xs12 sm4 class="pa-2">
<v-autocomplete
v-model="user.Details.Country"
:disabled="busy"
:label="$gettext('Country')" hide-details box flat
hide-no-data
:label="$gettext('Country')"
hide-no-data hide-details box flat
browser-autocomplete="off"
color="secondary-dark"
item-value="Code"
item-text="Name"
:items="countries"
class="input-country"
:rules="[v => validLength(v, 0, 2) || $gettext('Invalid')]"
@change="onChange"
>
</v-autocomplete>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-flex xs12 sm8 class="pa-2">
<v-text-field
v-model="user.Details.Phone"
hide-details required box flat
:disabled="busy"
maxlength="32"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Phone')"
class="input-phone"
color="secondary-dark"
:rules="[v => validLength(v, 0, 32) || $gettext('Invalid')]"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
v-model="user.Details.ProfileURL"
v-model="user.Details.SiteURL"
hide-details required box flat
:disabled="busy"
type="url"
maxlength="500"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Profile')"
class="input-profile-url"
:label="$gettext('Website')"
class="input-site-url"
color="secondary-dark"
:rules="[v => validUrl(v) || $gettext('Invalid')]"
@change="onChange"
></v-text-field>
</v-flex>
@ -234,28 +280,21 @@
hide-details required box flat
:disabled="busy"
type="url"
maxlength="500"
browser-autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Feed')"
class="input-feed-url"
color="secondary-dark"
:rules="[v => validUrl(v) || $gettext('Invalid')]"
@change="onChange"
></v-text-field>
</v-flex>
<v-flex xs12 class="pa-2">
<v-textarea v-model="user.Details.Bio" auto-grow flat box hide-details
rows="3" class="input-bio" color="secondary-dark"
autocorrect="off" autocapitalize="none" browser-autocomplete="off"
:disabled="busy"
:label="$gettext('Bio')"
@change="onChange"></v-textarea>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-form>
<p-about-footer></p-about-footer>
<p-account-password-dialog :show="dialog.password" @cancel="dialog.password = false" @confirm="dialog.password = false"></p-account-password-dialog>
<p-webdav-dialog :show="dialog.webdav" @close="dialog.webdav = false"></p-webdav-dialog>
</div>
@ -265,8 +304,8 @@
import PAccountPasswordDialog from "dialog/account/password.vue";
import countries from "options/countries.json";
import Notify from "common/notify";
import Api from "common/api";
import User from "model/user";
import * as options from "options/options";
export default {
name: 'PSettingsAccount',
@ -276,8 +315,10 @@ export default {
const isPublic = this.$config.isPublic();
return {
busy: isDemo || isPublic,
isDemo: isDemo,
isPublic: isPublic,
options,
isDemo,
isPublic,
valid: true,
rtl: this.$rtl,
user: new User(this.$session.getUser()),
countries: countries,
@ -287,11 +328,6 @@ export default {
},
};
},
created() {
if(this.isPublic && !this.isDemo) {
this.$router.push({ name: "settings" });
}
},
computed: {
displayName() {
const user = this.$session.getUser();
@ -302,6 +338,11 @@ export default {
return this.$gettext("Unregistered");
},
},
created() {
if(this.isPublic && !this.isDemo) {
this.$router.push({ name: "settings" });
}
},
methods: {
showDialog(name) {
if (!name) {
@ -312,6 +353,38 @@ export default {
disabled() {
return (this.isDemo || this.busy);
},
validEmail(v) {
if (typeof v !== "string" || v === "") {
return true;
} else if (!this.validLength(v, 0, 250)) {
return false;
}
return /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,32})+$/.test(v);
},
validLength(v, min, max) {
if (typeof v !== "string" && min <= 0) {
return true;
} else if (max > 0 && v.length > max) {
return false;
}
return v.length >= min;
},
validUrl(v) {
if (typeof v !== "string" || v === "") {
return true;
} else if (!this.validLength(v, 0, 500)) {
return false;
}
try {
new URL(v);
} catch (e) {
return false;
}
return true;
},
onChangeAvatar() {
if (this.busy) {
return;
@ -321,15 +394,19 @@ export default {
onLogout() {
this.$session.logout();
},
onChangeName() {
this.user.Details.NameSrc = "manual";
return this.onChange();
},
onChange() {
if (this.busy) {
if (this.busy || !this.valid) {
return;
}
this.busy = true;
this.user.update().then((u) => {
this.user = new User(u);
this.$session.setUser(u);
this.$notify.success(this.$gettext("Settings saved"));
this.$notify.success(this.$gettext("Changes successfully saved"));
}).finally(() => this.busy = false);
},
onUploadAvatar() {
@ -339,12 +416,12 @@ export default {
this.busy = true;
Notify.info(this.$gettext("Uploading…"));
Notify.info(this.$gettext("Updating picture…"));
this.user.uploadAvatar(this.$refs.upload.files).then((u) => {
this.user = new User(u);
this.$session.setUser(u);
this.$notify.success(this.$gettext("Settings saved"));
this.$notify.success(this.$gettext("Changes successfully saved"));
}).finally(() => this.busy = false);
}
},

View file

@ -369,7 +369,7 @@ export default {
this.busy = true;
this.settings.save().then(() => {
this.$notify.success(this.$gettext("Settings saved"));
this.$notify.success(this.$gettext("Changes successfully saved"));
}).finally(() => this.busy = false);
},
},

View file

@ -457,7 +457,7 @@ export default {
this.$notify.blockUI();
setTimeout(() => window.location.reload(), 100);
} else {
this.$notify.success(this.$gettext("Settings saved"));
this.$notify.success(this.$gettext("Changes successfully saved"));
}
}).finally(() => this.busy = false);
},

View file

@ -175,7 +175,7 @@ export default {
this.$notify.blockUI();
setTimeout(() => window.location.reload(), 100);
} else {
this.$notify.success(this.$gettext("Settings saved"));
this.$notify.success(this.$gettext("Changes successfully saved"));
}
}).finally(() => this.busy = false);
},

View file

@ -21,11 +21,10 @@ describe("common/session", () => {
const storage = new StorageShim();
const session = new Session(storage, config);
assert.equal(session.hasToken("2lbh9x09"), false);
assert.equal(session.session_id, null);
session.setId(123421);
assert.equal(session.session_id, 123421);
session.setId("999900000000000000000000000000000000000000000000");
assert.equal(session.session_id, "999900000000000000000000000000000000000000000000");
const result = session.getId();
assert.equal(result, 123421);
assert.equal(result, "999900000000000000000000000000000000000000000000");
session.deleteId();
assert.equal(session.session_id, null);
});

View file

@ -17,7 +17,7 @@ const clientConfig = {
debug: false,
readonly: false,
uploadNSFW: false,
public: true,
public: false,
experimental: true,
disableSettings: false,
test: true,

View file

@ -128,14 +128,40 @@ Mock.onDelete("api/v1/photos/pqbemz8276mhtobh/label/12345").reply(
{ success: "ok" },
mockHeaders
);
Mock.onPost("api/v1/session")
.reply(200, { id: "8877", data: { user: { ID: 1, PrimaryEmail: "test@test.com" } } }, mockHeaders)
.onDelete("api/v1/session/8877")
.reply(200)
.onDelete("api/v1/session/123")
.reply(200);
Mock.onPost("api/v1/session").reply(200, { id: "123", data: { token: "123token" } }, mockHeaders);
Mock.onPost("api/v1/session").reply(
200,
{
id: "999900000000000000000000000000000000000000000000",
data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
},
mockHeaders
);
Mock.onGet("api/v1/session/234200000000000000000000000000000000000000000000").reply(
200,
{
id: "234200000000000000000000000000000000000000000000",
data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
},
mockHeaders
);
Mock.onGet("api/v1/session/999900000000000000000000000000000000000000000000").reply(
200,
{
id: "999900000000000000000000000000000000000000000000",
data: { token: "123token" },
user: { ID: 1, UID: "urjysof3b9v7lgex", Name: "test", Email: "test@test.com" },
},
mockHeaders
);
Mock.onDelete("api/v1/session/999900000000000000000000000000000000000000000000").reply(200);
Mock.onDelete("api/v1/session/234200000000000000000000000000000000000000000000").reply(200);
Mock.onGet("api/v1/settings").reply(200, { download: true, language: "de" }, mockHeaders);
Mock.onPost("api/v1/settings").reply(200, { download: true, language: "en" }, mockHeaders);

View file

@ -43,7 +43,7 @@ func CreateSession(router *gin.RouterGroup) {
}
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Auth.Reject(ClientIP(c)) {
if limiter.Login.Reject(ClientIP(c)) {
limiter.AbortJSON(c)
return
}

View file

@ -84,7 +84,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
return
} else if !mimeType.Is(fs.MimeTypeJpeg) {
event.AuditWarn([]string{ClientIP(c), "session %s", "upload avatar", "only jpeg supported"}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrWrongFileType)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return
}
@ -101,7 +101,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
if mediaFile, mediaErr := photoprism.NewMediaFile(filePath); mediaErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
Abort(c, http.StatusBadRequest, i18n.ErrWrongFileType)
Abort(c, http.StatusBadRequest, i18n.ErrUnsupportedFormat)
return
} else if err = mediaFile.CreateThumbnails(conf.ThumbCachePath(), false); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)

View file

@ -29,7 +29,7 @@ func UpdateUserPassword(router *gin.RouterGroup) {
}
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Auth.Reject(ClientIP(c)) {
if limiter.Login.Reject(ClientIP(c)) {
limiter.AbortJSON(c)
return
}
@ -63,7 +63,7 @@ func UpdateUserPassword(router *gin.RouterGroup) {
// Verify that the old password is correct.
if u.WrongPassword(f.OldPassword) {
limiter.Auth.Reserve(ClientIP(c))
limiter.Login.Reserve(ClientIP(c))
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return
}

View file

@ -3,16 +3,14 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/get"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/clean"
)
// UpdateUser updates the profile information of the currently authenticated user.
@ -42,8 +40,8 @@ func UpdateUser(router *gin.RouterGroup) {
return
}
// 1) Init form with model values
f, err := form.NewUser(m)
// Init form with model values.
f, err := m.Form()
if err != nil {
log.Error(err)
@ -51,14 +49,14 @@ func UpdateUser(router *gin.RouterGroup) {
return
}
// 2) Update form with values from request
// Update form with values from request.
if err = c.BindJSON(&f); err != nil {
log.Error(err)
AbortBadRequest(c)
return
}
// 3) Save model with values from form
// Save model with values from form.
if err = m.SaveForm(f); err != nil {
log.Error(err)
AbortSaveFailed(c)

View file

@ -2,6 +2,7 @@ package commands
import (
"fmt"
"strings"
"github.com/sirupsen/logrus"
"github.com/urfave/cli"
@ -48,7 +49,7 @@ func showConfigAction(ctx *cli.Context) error {
// Show report.
if opt.Format == report.Default {
fmt.Printf("### %s ###\n\n", rep.Title)
fmt.Printf("%s\n\n", strings.ToUpper(rep.Title))
}
fmt.Println(result)

View file

@ -9,6 +9,8 @@ import (
"syscall"
"time"
"github.com/photoprism/photoprism/pkg/report"
"github.com/sevlyar/go-daemon"
"github.com/urfave/cli"
@ -26,7 +28,7 @@ import (
var StartCommand = cli.Command{
Name: "start",
Aliases: []string{"up"},
Usage: "Starts the web server",
Usage: "Starts the Web server",
Flags: startFlags,
Action: startAction,
}
@ -53,13 +55,20 @@ func startAction(ctx *cli.Context) error {
}
if ctx.IsSet("config") {
fmt.Printf("Name Value\n")
fmt.Printf("detach-server %t\n", conf.DetachServer())
fmt.Printf("http-host %s\n", conf.HttpHost())
fmt.Printf("http-port %d\n", conf.HttpPort())
fmt.Printf("http-mode %s\n", conf.HttpMode())
// Create config report.
cols := []string{"Name", "Value"}
rows := [][]string{
{"detach-server", fmt.Sprintf("%t", conf.DetachServer())},
{"http-mode", conf.HttpMode()},
{"http-compression", conf.HttpCompression()},
{"http-host", conf.HttpHost()},
{"http-port", fmt.Sprintf("%d", conf.HttpPort())},
}
// Render config report.
opt := report.Options{Format: report.CliFormat(ctx), NoWrap: true}
result, _ := report.Render(rows, cols, opt)
fmt.Printf("\n%s\n", result)
return nil
}

View file

@ -15,7 +15,7 @@ import (
// StatusCommand registers the status command.
var StatusCommand = cli.Command{
Name: "status",
Usage: "Checks if the web server is running",
Usage: "Checks if the Web server is running",
Action: statusAction,
}

View file

@ -13,7 +13,7 @@ import (
var StopCommand = cli.Command{
Name: "stop",
Aliases: []string{"down"},
Usage: "Stops the web server in daemon mode",
Usage: "Stops the Web server in daemon mode",
Action: stopAction,
}

View file

@ -89,12 +89,6 @@ func (c *Config) CreateDirectories() error {
return createError(c.StoragePath(), err)
}
if c.FilesPath() == "" {
return notFoundError("files")
} else if err := os.MkdirAll(c.FilesPath(), os.ModePerm); err != nil {
return createError(c.FilesPath(), err)
}
if c.UsersPath() == "" {
return notFoundError("users")
} else if err := os.MkdirAll(c.UsersPath(), os.ModePerm); err != nil {
@ -149,10 +143,10 @@ func (c *Config) CreateDirectories() error {
return createError(c.ConfigPath(), err)
}
if c.CertsPath() == "" {
return notFoundError("certs")
} else if err := os.MkdirAll(c.CertsPath(), os.ModePerm); err != nil {
return createError(c.CertsPath(), err)
if c.CertificatesPath() == "" {
return notFoundError("certificates")
} else if err := os.MkdirAll(c.CertificatesPath(), os.ModePerm); err != nil {
return createError(c.CertificatesPath(), err)
}
if c.TempPath() == "" {
@ -310,31 +304,6 @@ func (c *Config) SidecarWritable() bool {
return !c.ReadOnly() || c.SidecarPathIsAbs()
}
// FilesPath returns the storage base path for files that should not be indexed.
func (c *Config) FilesPath() string {
// Set default.
if c.options.FilesPath == "" {
c.options.FilesPath = filepath.Join(c.StoragePath(), "files")
}
return c.options.FilesPath
}
// FilePath returns the file storage path based on the hash provided.
func (c *Config) FilePath(fileHash string) string {
if !rnd.IsHex(fileHash) || len(fileHash) < 4 {
return ""
}
dir := filepath.Join(c.FilesPath(), fileHash[0:1], fileHash[1:2], fileHash[2:3])
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return ""
}
return filepath.Join(dir, fileHash)
}
// UsersPath returns the storage base path for user assets like
// avatar images and other media that should not be indexed.
func (c *Config) UsersPath() string {

View file

@ -23,17 +23,6 @@ func TestConfig_SidecarPath(t *testing.T) {
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/sidecar", c.SidecarPath())
}
func TestConfig_FilePath(t *testing.T) {
c := NewConfig(CliTestContext())
t.Run("Valid", func(t *testing.T) {
s := c.FilePath("c476503628b4543c9ef97d69a6daa700b05d19bc")
assert.True(t, strings.HasSuffix(s, "/c/4/7/c476503628b4543c9ef97d69a6daa700b05d19bc"))
})
t.Run("InvalidHash", func(t *testing.T) {
assert.Equal(t, "", c.FilePath("YE"))
})
}
func TestConfig_UsersPath(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Contains(t, c.UsersPath(), "testdata/users")

View file

@ -1,82 +0,0 @@
package config
import (
"path/filepath"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// CertsPath returns the path to the TLS certificates and keys.
func (c *Config) CertsPath() string {
return filepath.Join(c.ConfigPath(), "certs")
}
// AutoTLS returns the email address for enabling automatic HTTPS via Let's Encrypt.
func (c *Config) AutoTLS() string {
return clean.Email(c.options.AutoTLS)
}
// TLSKey returns the HTTPS private key filename.
func (c *Config) TLSKey() string {
if c.options.TLSKey == "" {
return ""
} else if fs.FileExistsNotEmpty(c.options.TLSKey) {
return c.options.TLSKey
} else if fileName := filepath.Join(c.CertsPath(), c.options.TLSKey); fs.FileExistsNotEmpty(fileName) {
return fileName
}
return ""
}
// TLSCert returns the HTTPS certificate filename.
func (c *Config) TLSCert() string {
if c.options.TLSCert == "" {
return ""
} else if fs.FileExistsNotEmpty(c.options.TLSCert) {
return c.options.TLSCert
} else if fileName := filepath.Join(c.CertsPath(), c.options.TLSCert); fs.FileExistsNotEmpty(fileName) {
return fileName
}
return ""
}
// TLS returns the HTTPS certificate and private key file name.
func (c *Config) TLS() (certFile, privateKey string) {
certFile = c.TLSCert()
privateKey = c.TLSKey()
if c.options.TLSCert == "" || privateKey == "" {
return "", ""
}
return certFile, privateKey
}
// HttpsPort returns the HTTPS server port number.
func (c *Config) HttpsPort() int {
if !c.SiteHttps() {
return -1
}
if c.options.HttpsPort == 0 {
return 2443
}
return c.options.HttpsPort
}
// HttpsRedirect returns the HTTPS redirect status code.
func (c *Config) HttpsRedirect() int {
if !c.SiteHttps() {
return -1
}
if c.options.HttpsRedirect > 0 && c.options.HttpsRedirect < 300 && c.options.HttpsRedirect >= 400 {
return 301
}
return c.options.HttpsRedirect
}

View file

@ -1,62 +0,0 @@
package config
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfig_CertsPath(t *testing.T) {
c := NewConfig(CliTestContext())
if dir := c.CertsPath(); dir == "" {
t.Fatal("certs path is empty")
} else if !strings.HasPrefix(dir, c.ConfigPath()) {
t.Fatalf("unexpected certs path: %s", dir)
}
}
func TestConfig_AutoTLS(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.AutoTLS())
c.options.AutoTLS = "hello@example.com"
assert.Equal(t, "hello@example.com", c.AutoTLS())
c.options.AutoTLS = "hello"
assert.Equal(t, "", c.AutoTLS())
c.options.AutoTLS = ""
assert.Equal(t, "", c.AutoTLS())
}
func TestConfig_TLS(t *testing.T) {
c := NewConfig(CliTestContext())
cert, key := c.TLS()
assert.Equal(t, "", cert)
assert.Equal(t, "", key)
}
func TestConfig_TLSKey(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSKey())
}
func TestConfig_TLSCert(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSCert())
}
func TestConfig_HttpsPort(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, -1, c.HttpsPort())
}
func TestConfig_HttpsRedirect(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, -1, c.HttpsRedirect())
}

View file

@ -0,0 +1,86 @@
package config
import (
"path/filepath"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
const (
PrivateKeyExt = ".key"
PublicCertExt = ".crt"
)
// CertificatesPath returns the path to the TLS certificates and keys.
func (c *Config) CertificatesPath() string {
return filepath.Join(c.ConfigPath(), "certificates")
}
// TLSEmail returns the email address to enable automatic HTTPS via Let's Encrypt
func (c *Config) TLSEmail() string {
return clean.Email(c.options.TLSEmail)
}
// TLSCert returns the public certificate required to enable TLS.
func (c *Config) TLSCert() string {
certName := c.options.TLSCert
if certName == "" {
certName = c.SiteDomain() + PublicCertExt
} else if fs.FileExistsNotEmpty(certName) {
return certName
}
// Find and return public certificate.
if fileName := filepath.Join(c.CertificatesPath(), certName); fs.FileExistsNotEmpty(fileName) {
return fileName
}
return ""
}
// TLSKey returns the private key required to enable TLS.
func (c *Config) TLSKey() string {
keyName := c.options.TLSKey
if keyName == "" {
keyName = c.SiteDomain() + PrivateKeyExt
} else if fs.FileExistsNotEmpty(keyName) {
return keyName
}
// Find and return private key.
if fileName := filepath.Join(c.CertificatesPath(), keyName); fs.FileExistsNotEmpty(fileName) {
return fileName
}
return ""
}
// TLS returns the HTTPS certificate and private key file name.
func (c *Config) TLS() (publicCert, privateKey string) {
privateKey = c.TLSKey()
if privateKey == "" {
return "", ""
}
publicCert = c.TLSCert()
if publicCert == "" {
return "", ""
}
return publicCert, privateKey
}
// DisableTLS checks if HTTPS should be disabled.
func (c *Config) DisableTLS() bool {
if c.options.DisableTLS {
return true
} else if !c.SiteHttps() {
return true
}
return c.TLSCert() == "" || c.TLSKey() == ""
}

View file

@ -0,0 +1,56 @@
package config
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfig_CertificatesPath(t *testing.T) {
c := NewConfig(CliTestContext())
if dir := c.CertificatesPath(); dir == "" {
t.Fatal("certificates path is empty")
} else if !strings.HasPrefix(dir, c.ConfigPath()) {
t.Fatalf("unexpected certificates path: %s", dir)
}
}
func TestConfig_TLSEmail(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSEmail())
c.options.TLSEmail = "hello@example.com"
assert.Equal(t, "hello@example.com", c.TLSEmail())
c.options.TLSEmail = "hello"
assert.Equal(t, "", c.TLSEmail())
c.options.TLSEmail = ""
assert.Equal(t, "", c.TLSEmail())
}
func TestConfig_TLSCert(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSCert())
}
func TestConfig_TLSKey(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, "", c.TLSKey())
}
func TestConfig_TLS(t *testing.T) {
c := NewConfig(CliTestContext())
cert, key := c.TLS()
assert.Equal(t, "", cert)
assert.Equal(t, "", key)
}
func TestConfig_DisableTLS(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, c.DisableTLS())
}

View file

@ -397,39 +397,6 @@ var Flags = CliFlags{
Value: &cli.StringSlice{header.CidrDockerInternal},
EnvVar: "PHOTOPRISM_TRUSTED_PROXY",
}}, {
Flag: cli.StringFlag{
Name: "http-mode, mode",
Usage: "HTTP server `MODE` (debug, release, or test)",
EnvVar: "PHOTOPRISM_HTTP_MODE",
}}, {
Flag: cli.StringFlag{
Name: "http-compression, z",
Usage: "HTTP server compression `METHOD` (none or gzip)",
EnvVar: "PHOTOPRISM_HTTP_COMPRESSION",
}}, {
Flag: cli.StringFlag{
Name: "http-host, ip",
Usage: "HTTP server `IP` address",
EnvVar: "PHOTOPRISM_HTTP_HOST",
}}, {
Flag: cli.IntFlag{
Name: "http-port, port",
Value: 2342,
Usage: "HTTP server port `NUMBER`",
EnvVar: "PHOTOPRISM_HTTP_PORT",
}}, {
Flag: cli.StringFlag{
Name: "auto-tls",
Usage: "`EMAIL` address to enable automatic HTTPS via Let's Encrypt",
EnvVar: "PHOTOPRISM_AUTO_TLS",
Hidden: true,
}}, {
Flag: cli.IntFlag{
Name: "https-port",
Value: 2443,
Usage: "HTTPS server port `NUMBER` if TLS is configured",
EnvVar: "PHOTOPRISM_HTTPS_PORT",
}}, {
Flag: cli.StringSliceFlag{
Name: "https-proxy-header",
Usage: "proxy protocol header `NAME`",
@ -442,11 +409,26 @@ var Flags = CliFlags{
Value: &cli.StringSlice{header.ProtoHttps},
EnvVar: "PHOTOPRISM_HTTPS_PROXY_PROTO",
}}, {
Flag: cli.StringFlag{
Name: "http-mode, mode",
Usage: "Web server `MODE` (debug, release, or test)",
EnvVar: "PHOTOPRISM_HTTP_MODE",
}}, {
Flag: cli.StringFlag{
Name: "http-compression, z",
Usage: "Web server compression `METHOD` (none or gzip)",
EnvVar: "PHOTOPRISM_HTTP_COMPRESSION",
}}, {
Flag: cli.StringFlag{
Name: "http-host, ip",
Usage: "Web server `IP` address",
EnvVar: "PHOTOPRISM_HTTP_HOST",
}}, {
Flag: cli.IntFlag{
Name: "https-redirect",
Value: 0,
Usage: "status `CODE` when redirecting from HTTP to HTTPS (300-399)",
EnvVar: "PHOTOPRISM_HTTPS_REDIRECT",
Name: "http-port, port",
Value: 2342,
Usage: "Web server port `NUMBER`",
EnvVar: "PHOTOPRISM_HTTP_PORT",
}}, {
Flag: cli.StringFlag{
Name: "database-driver, db",

View file

@ -45,7 +45,6 @@ type Options struct {
ResolutionLimit int `yaml:"ResolutionLimit" json:"ResolutionLimit" flag:"resolution-limit"`
StoragePath string `yaml:"StoragePath" json:"-" flag:"storage-path"`
SidecarPath string `yaml:"SidecarPath" json:"-" flag:"sidecar-path"`
FilesPath string `yaml:"FilesPath" json:"-" flag:"files-path"`
UsersPath string `yaml:"UsersPath" json:"-" flag:"users-path"`
BackupPath string `yaml:"BackupPath" json:"-" flag:"backup-path"`
CachePath string `yaml:"CachePath" json:"-" flag:"cache-path"`
@ -92,17 +91,16 @@ type Options struct {
SiteDescription string `yaml:"SiteDescription" json:"SiteDescription" flag:"site-description"`
SitePreview string `yaml:"SitePreview" json:"SitePreview" flag:"site-preview"`
TrustedProxies []string `yaml:"TrustedProxies" json:"-" flag:"trusted-proxy"`
HttpsProxyHeaders []string `yaml:"HttpsProxyHeaders" json:"-" flag:"https-proxy-header"`
HttpsProxyProto []string `yaml:"HttpsProxyProto" json:"-" flag:"https-proxy-proto"`
HttpMode string `yaml:"HttpMode" json:"-" flag:"http-mode"`
HttpCompression string `yaml:"HttpCompression" json:"-" flag:"http-compression"`
HttpHost string `yaml:"HttpHost" json:"-" flag:"http-host"`
HttpPort int `yaml:"HttpPort" json:"-" flag:"http-port"`
AutoTLS string `yaml:"AutoTLS" json:"AutoTLS" flag:"auto-tls"` // AutoTLS enabled automatic HTTPS via Let's Encrypt if set a valid email address.
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
TLSEmail string `yaml:"TLSEmail" json:"TLSEmail" flag:"tls-email"` // TLSEmail enabled automatic HTTPS via Let's Encrypt if set a valid email address.
TLSCert string `yaml:"TLSCert" json:"TLSCert" flag:"tls-cert"`
HttpsPort int `yaml:"HttpsPort" json:"HttpsPort" flag:"https-port"` // HttpsPort is the port number to be used for HTTPS connections.
HttpsProxyHeaders []string `yaml:"HttpsProxyHeaders" json:"-" flag:"https-proxy-header"`
HttpsProxyProto []string `yaml:"HttpsProxyProto" json:"-" flag:"https-proxy-proto"`
HttpsRedirect int `yaml:"HttpsRedirect" json:"HttpsRedirect" flag:"https-redirect"`
TLSKey string `yaml:"TLSKey" json:"TLSKey" flag:"tls-key"`
DisableTLS bool `yaml:"DisableTLS" json:"DisableTLS" flag:"disable-tls"`
DatabaseDriver string `yaml:"DatabaseDriver" json:"-" flag:"database-driver"`
DatabaseDsn string `yaml:"DatabaseDsn" json:"-" flag:"database-dsn"`
DatabaseName string `yaml:"DatabaseName" json:"-" flag:"database-name"`

View file

@ -35,7 +35,7 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// Config.
{"config-path", c.ConfigPath()},
{"certs-path", c.CertsPath()},
{"certificates-path", c.CertificatesPath()},
{"options-yaml", c.OptionsYaml()},
{"defaults-yaml", c.DefaultsYaml()},
{"settings-yaml", c.SettingsYaml()},
@ -48,7 +48,6 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// Other paths.
{"storage-path", c.StoragePath()},
{"sidecar-path", c.SidecarPath()},
{"files-path", c.FilesPath()},
{"users-path", c.UsersPath()},
{"albums-path", c.AlbumsPath()},
{"backup-path", c.BackupPath()},
@ -109,6 +108,8 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// Site Infos.
{"cdn-url", c.CdnUrl("/")},
{"site-url", c.SiteUrl()},
{"site-https", fmt.Sprintf("%t", c.SiteHttps())},
{"site-domain", c.SiteDomain()},
{"site-author", c.SiteAuthor()},
{"site-title", c.SiteTitle()},
{"site-caption", c.SiteCaption()},
@ -127,17 +128,18 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// HTTP(S) Proxy.
{"trusted-proxy", c.TrustedProxy()},
{"https-proxy-header", strings.Join(c.HttpsProxyHeader(), ", ")},
{"https-proxy-proto", strings.Join(c.HttpsProxyProto(), ", ")},
// Web Server.
{"http-mode", c.HttpMode()},
{"http-compression", c.HttpCompression()},
{"http-host", c.HttpHost()},
{"http-port", fmt.Sprintf("%d", c.HttpPort())},
{"auto-tls", c.AutoTLS()},
{"https-port", fmt.Sprintf("%d", c.HttpsPort())},
{"https-proxy-header", strings.Join(c.HttpsProxyHeader(), ", ")},
{"https-proxy-proto", strings.Join(c.HttpsProxyProto(), ", ")},
{"https-redirect", fmt.Sprintf("%d", c.HttpsRedirect())},
{"tls-email", c.TLSEmail()},
{"tls-cert", c.TLSCert()},
{"tls-key", c.TLSKey()},
{"disable-tls", fmt.Sprintf("%t", c.DisableTLS())},
// Database.
{"database-driver", c.DatabaseDriver()},

View file

@ -33,7 +33,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
// User found?
if user == nil {
message := "account not found"
limiter.Auth.Reserve(m.IP())
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
@ -52,7 +52,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
// Password valid?
if user.WrongPassword(f.Password) {
message := "incorrect password"
limiter.Auth.Reserve(m.IP())
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
@ -72,7 +72,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
// Redeem token.
if user.IsRegistered() {
if shares := user.RedeemToken(f.AuthToken); shares == 0 {
limiter.Auth.Reserve(m.IP())
limiter.Login.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.AuthToken))
m.Status = http.StatusNotFound
return i18n.Error(i18n.ErrInvalidLink)
@ -83,7 +83,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
m.Status = http.StatusInternalServerError
return i18n.Error(i18n.ErrUnexpected)
} else if shares := data.RedeemToken(f.AuthToken); shares == 0 {
limiter.Auth.Reserve(m.IP())
limiter.Login.Reserve(m.IP())
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.AuthToken))
event.LoginError(m.IP(), "api", "", m.UserAgent, "invalid share token")
m.Status = http.StatusNotFound

View file

@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/ulule/deepcopier"
"github.com/jinzhu/gorm"
@ -115,6 +117,7 @@ func FindUser(find User) *User {
// Find matching record.
if err := stmt.First(m).Error; err != nil {
event.AuditErr([]string{"user", "not found", "%s"}, err)
return nil
}
@ -385,18 +388,26 @@ func (m *User) Name() string {
// SetName sets the login username to the specified string.
func (m *User) SetName(login string) (err error) {
if m.ID < 0 {
return fmt.Errorf("system users cannot be modified")
}
login = clean.Username(login)
// Empty?
if login == "" {
return fmt.Errorf("username cannot be empty")
return fmt.Errorf("username is empty")
} else if m.UserName == login {
return nil
} else if m.UserName != "" && m.ID != 1 {
return fmt.Errorf("username cannot be changed")
}
// Update username and slug.
m.UserName = login
// Update display name.
if m.DisplayName == "" || m.DisplayName == AdminDisplayName {
if m.DisplayName == "" || m.DisplayName == AdminDisplayName && m.ID == 1 {
m.DisplayName = clean.NameCapitalized(login)
}
@ -767,20 +778,43 @@ func (m *User) RedeemToken(token string) (n int) {
return n
}
// Form returns a populated user form to perform changes.
func (m *User) Form() (form.User, error) {
frm := form.User{UserDetails: &form.UserDetails{}}
if err := deepcopier.Copy(m).To(&frm); err != nil {
return frm, err
}
if err := deepcopier.Copy(m.UserDetails).To(frm.UserDetails); err != nil {
return frm, err
}
return frm, nil
}
// SaveForm updates the entity using form data and stores it in the database.
func (m *User) SaveForm(f form.User) error {
if m.UserName == "" || m.ID <= 0 {
return fmt.Errorf("system users cannot be updated")
}
if err := deepcopier.Copy(m.UserDetails).From(f.UserDetails); err != nil {
// Ignore details if not set.
if f.UserDetails == nil {
// Ignore.
} else if err := deepcopier.Copy(f.UserDetails).To(m.UserDetails); err != nil {
return err
} else {
m.UserDetails.UserAbout = strings.TrimSpace(m.UserDetails.UserAbout)
m.UserDetails.UserBio = strings.TrimSpace(m.UserDetails.UserBio)
}
// Sanitize display name.
if n := clean.Name(f.DisplayName); n != "" && n != m.DisplayName {
m.DisplayName = n
m.SetDisplayName(n)
}
// Sanitize email address.
if email := clean.Email(f.UserEmail); email != "" && email != m.UserEmail {
m.UserEmail = email
m.VerifiedAt = nil
@ -790,6 +824,36 @@ func (m *User) SaveForm(f form.User) error {
return m.Save()
}
// SetDisplayName sets a new display name and, if possible, splits it into its components.
func (m *User) SetDisplayName(name string) *User {
name = clean.Name(name)
// Empty?
if name == "" {
return m
}
m.DisplayName = name
d := m.Details()
if SrcPriority[SrcAuto] < SrcPriority[d.NameSrc] {
return m
}
// Try to parse name into components.
n := txt.ParseName(name)
d.NameTitle = n.Title
d.GivenName = n.Given
d.MiddleName = n.Middle
d.FamilyName = n.Family
d.NameSuffix = n.Suffix
d.NickName = n.Nick
return m
}
// SetAvatar updates the user avatar image.
func (m *User) SetAvatar(thumb, thumbSrc string) error {
if m.UserName == "" || m.ID <= 0 {

View file

@ -17,22 +17,23 @@ const (
// UserDetails represents user profile information.
type UserDetails struct {
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"-"`
IdURL string `gorm:"type:VARBINARY(512);column:id_url;" json:"IdURL,omitempty" yaml:"IdURL,omitempty"`
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID,omitempty" yaml:"SubjUID,omitempty"`
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"-" yaml:"SubjSrc,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"-" yaml:"-"`
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"-" yaml:"PlaceSrc,omitempty"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"-" yaml:"CellID,omitempty"`
BirthYear int `gorm:"default:-1;" json:"BirthYear" yaml:"BirthYear,omitempty"`
BirthMonth int `gorm:"default:-1;" json:"BirthMonth" yaml:"BirthMonth,omitempty"`
BirthDay int `gorm:"default:-1;" json:"BirthDay" yaml:"BirthDay,omitempty"`
NamePrefix string `gorm:"size:32;" json:"NamePrefix" yaml:"NamePrefix,omitempty"`
BirthYear int `json:"BirthYear" yaml:"BirthYear,omitempty"`
BirthMonth int `json:"BirthMonth" yaml:"BirthMonth,omitempty"`
BirthDay int `json:"BirthDay" yaml:"BirthDay,omitempty"`
NameTitle string `gorm:"size:32;" json:"NameTitle" yaml:"NameTitle,omitempty"`
GivenName string `gorm:"size:64;" json:"GivenName" yaml:"GivenName,omitempty"`
MiddleName string `gorm:"size:64;" json:"MiddleName" yaml:"MiddleName,omitempty"`
FamilyName string `gorm:"size:64;" json:"FamilyName" yaml:"FamilyName,omitempty"`
NameSuffix string `gorm:"size:32;" json:"NameSuffix" yaml:"NameSuffix,omitempty"`
NickName string `gorm:"size:64;" json:"NickName" yaml:"NickName,omitempty"`
NameSrc string `gorm:"type:VARBINARY(8);" json:"NameSrc" yaml:"NameSrc,omitempty"`
UserGender string `gorm:"size:16;" json:"Gender" yaml:"Gender,omitempty"`
UserAbout string `gorm:"size:512;" json:"About" yaml:"About,omitempty"`
UserBio string `gorm:"size:512;" json:"Bio" yaml:"Bio,omitempty"`
UserLocation string `gorm:"size:512;" json:"Location" yaml:"Location,omitempty"`
UserCountry string `gorm:"type:VARBINARY(2);" json:"Country" yaml:"Country,omitempty"`
@ -41,11 +42,12 @@ type UserDetails struct {
ProfileURL string `gorm:"type:VARBINARY(512);column:profile_url" json:"ProfileURL" yaml:"ProfileURL,omitempty"`
FeedURL string `gorm:"type:VARBINARY(512);column:feed_url" json:"FeedURL,omitempty" yaml:"FeedURL,omitempty"`
AvatarURL string `gorm:"type:VARBINARY(512);column:avatar_url" json:"AvatarURL,omitempty" yaml:"AvatarURL,omitempty"`
OrgName string `gorm:"size:128;" json:"OrgName" yaml:"OrgName,omitempty"`
OrgTitle string `gorm:"size:64;" json:"OrgTitle" yaml:"OrgTitle,omitempty"`
OrgName string `gorm:"size:128;" json:"OrgName" yaml:"OrgName,omitempty"`
OrgEmail string `gorm:"size:255;index;" json:"OrgEmail" yaml:"OrgEmail,omitempty"`
OrgPhone string `gorm:"size:32;" json:"OrgPhone" yaml:"OrgPhone,omitempty"`
OrgURL string `gorm:"type:VARBINARY(512);column:org_url" json:"OrgURL" yaml:"OrgURL,omitempty"`
IdURL string `gorm:"type:VARBINARY(512);column:id_url;" json:"IdURL,omitempty" yaml:"IdURL,omitempty"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
}

View file

@ -117,12 +117,9 @@ func TestUser_Create(t *testing.T) {
assert.Equal(t, "example", m.Name())
assert.Equal(t, "example", m.UserName)
if err := m.UpdateName("example-editor"); err != nil {
t.Fatal(err)
if err := m.UpdateName("example-editor"); err == nil {
t.Fatal("error expected")
}
assert.Equal(t, "example-editor", m.Name())
assert.Equal(t, "example-editor", m.UserName)
})
t.Run("NewUser", func(t *testing.T) {
if err := NewUser().Create(); err != nil {
@ -612,9 +609,9 @@ func TestUser_Validate(t *testing.T) {
})
t.Run("RoleEmpty", func(t *testing.T) {
u := &User{
UserName: "jens.mander",
UserEmail: "jens@mander.de",
DisplayName: "Jens Mander",
UserName: "test.example",
UserEmail: "test@example.com",
DisplayName: "Test Example",
UserRole: "",
}
@ -622,9 +619,9 @@ func TestUser_Validate(t *testing.T) {
})
t.Run("RoleAdmin", func(t *testing.T) {
u := &User{
UserName: "jens.mander",
UserEmail: "jens@mander.de",
DisplayName: "Jens Mander",
UserName: "test.example",
UserEmail: "test@example.com",
DisplayName: "Test Example",
UserRole: acl.RoleAdmin.String(),
}
@ -632,9 +629,9 @@ func TestUser_Validate(t *testing.T) {
})
t.Run("RoleInvalid", func(t *testing.T) {
u := &User{
UserName: "jens.mander",
UserEmail: "jens@mander.de",
DisplayName: "Jens Mander",
UserName: "test.example",
UserEmail: "test@example.com",
DisplayName: "Test Example",
UserRole: "foobar",
}
@ -736,31 +733,74 @@ func TestUser_SharedUIDs(t *testing.T) {
})
}
func TestUser_Form(t *testing.T) {
t.Run("Alice", func(t *testing.T) {
m := FindUserByName("alice")
if m == nil {
t.Fatal("result should not be nil")
}
frm, err := m.Form()
if err != nil {
t.Fatal(err)
}
t.Logf("User: %#v", m)
t.Logf("User.UserDetails: %#v", m.UserDetails)
t.Logf("Form: %#v", frm)
t.Logf("Form.UserDetails: %#v", frm.UserDetails)
})
}
func TestUser_SaveForm(t *testing.T) {
t.Run("UnknownUser", func(t *testing.T) {
frm, err := form.NewUser(UnknownUser)
frm, err := UnknownUser.Form()
assert.NoError(t, err)
err = UnknownUser.SaveForm(frm)
assert.Error(t, err)
})
t.Run("Admin", func(t *testing.T) {
frm, err := form.NewUser(Admin)
assert.NoError(t, err)
m := FindUser(Admin)
if m == nil {
t.Fatal("result should not be nil")
}
frm, err := m.Form()
if err != nil {
t.Fatal(err)
}
frm.UserEmail = "admin@example.com"
frm.UserDetails.UserLocation = "GoLand"
err = Admin.SaveForm(frm)
assert.NoError(t, err)
assert.Equal(t, "admin@example.com", Admin.UserEmail)
assert.Equal(t, "GoLand", Admin.Details().UserLocation)
m := FindUserByUID(Admin.UserUID)
m = FindUserByUID(Admin.UserUID)
assert.Equal(t, "admin@example.com", m.UserEmail)
assert.Equal(t, "GoLand", m.Details().UserLocation)
})
}
func TestUser_SetDisplayName(t *testing.T) {
t.Run("BillGates", func(t *testing.T) {
user := NewUser()
user.SetDisplayName("Sir William Henry Gates III")
d := user.Details()
assert.Equal(t, "Sir", d.NameTitle)
assert.Equal(t, "William", d.GivenName)
assert.Equal(t, "Henry Gates", d.FamilyName)
assert.Equal(t, "III", d.NameSuffix)
})
}
func TestUser_SetAvatar(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
err := Visitor.SetAvatar("ebfc0aea7d3fd018b5fff57c76806b35181855ed", SrcManual)

View file

@ -104,12 +104,19 @@ class auth_users_details {
varbinary(42) place_id
varbinary(8) place_src
varbinary(42) cell_id
int(11) birth_year
int(11) birth_month
int(11) birth_day
varchar(512) user_bio
varchar(32) user_phone
varchar(512) user_location
varbinary(2) user_country
varchar(32) user_phone
varbinary(512) site_url
varbinary(512) profile_url
varbinary(512) feed_url
varbinary(512) avatar_url
varchar(16) user_gender
varchar(32) name_prefix
varchar(32) name_title
varchar(64) given_name
varchar(64) middle_name
varchar(64) family_name
@ -119,12 +126,6 @@ class auth_users_details {
varchar(128) org_name
varbinary(512) org_url
varbinary(512) id_url
varbinary(512) site_url
varbinary(512) feed_url
varbinary(512) avatar_url
int(11) birth_year
int(11) birth_month
int(11) birth_day
datetime created_at
datetime updated_at
varbinary(42) user_uid

View file

@ -158,12 +158,19 @@ CREATE TABLE `auth_users_details` (
`place_id` varbinary(42) DEFAULT 'zz',
`place_src` varbinary(8) DEFAULT NULL,
`cell_id` varbinary(42) DEFAULT 'zz',
`birth_year` int(11) DEFAULT -1,
`birth_month` int(11) DEFAULT -1,
`birth_day` int(11) DEFAULT -1,
`user_bio` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_phone` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_location` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`user_country` varbinary(2) DEFAULT NULL,
`user_phone` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`site_url` varbinary(512) DEFAULT NULL,
`profile_url` varbinary(512) DEFAULT NULL,
`feed_url` varbinary(512) DEFAULT NULL,
`avatar_url` varbinary(512) DEFAULT NULL,
`user_gender` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`name_prefix` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`name_title` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`given_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`middle_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`family_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
@ -171,14 +178,10 @@ CREATE TABLE `auth_users_details` (
`nick_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_title` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_phone` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`org_url` varbinary(512) DEFAULT NULL,
`id_url` varbinary(512) DEFAULT NULL,
`site_url` varbinary(512) DEFAULT NULL,
`feed_url` varbinary(512) DEFAULT NULL,
`avatar_url` varbinary(512) DEFAULT NULL,
`birth_year` int(11) DEFAULT -1,
`birth_month` int(11) DEFAULT -1,
`birth_day` int(11) DEFAULT -1,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`user_uid`),

View file

@ -1,7 +1,6 @@
package form
import (
"github.com/ulule/deepcopier"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/pkg/clean"
@ -9,24 +8,18 @@ import (
// User represents a user account form.
type User struct {
UserName string `json:"Name" yaml:"Name,omitempty"`
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
Password string `json:"Password,omitempty" yaml:"Password,omitempty"`
UserDetails UserDetails `json:"Details"`
}
// NewUser creates a new user account form.
func NewUser(m interface{}) (f User, err error) {
err = deepcopier.Copy(m).To(&f)
return f, err
UserName string `json:"Name,omitempty" yaml:"Name,omitempty"`
UserEmail string `json:"Email,omitempty" yaml:"Email,omitempty"`
DisplayName string `json:"DisplayName,omitempty" yaml:"DisplayName,omitempty"`
UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"`
BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
Password string `json:"Password,omitempty" yaml:"Password,omitempty"`
UserDetails *UserDetails `json:"Details,omitempty"`
}
// NewUserFromCli creates a new form with values from a CLI context.

View file

@ -5,13 +5,15 @@ type UserDetails struct {
BirthYear int `json:"BirthYear"`
BirthMonth int `json:"BirthMonth"`
BirthDay int `json:"BirthDay"`
NamePrefix string `json:"NamePrefix"`
NameTitle string `json:"NameTitle"`
GivenName string `json:"GivenName"`
MiddleName string `json:"MiddleName"`
FamilyName string `json:"FamilyName"`
NameSuffix string `json:"NameSuffix"`
NickName string `json:"NickName"`
NameSrc string `json:"NameSrc"`
UserGender string `json:"Gender"`
UserAbout string `json:"About"`
UserBio string `json:"Bio"`
UserLocation string `json:"Location"`
UserCountry string `json:"Country"`
@ -19,8 +21,8 @@ type UserDetails struct {
SiteURL string `json:"SiteURL"`
ProfileURL string `json:"ProfileURL"`
FeedURL string `json:"FeedURL"`
OrgName string `json:"OrgName"`
OrgTitle string `json:"OrgTitle"`
OrgName string `json:"OrgName"`
OrgEmail string `json:"OrgEmail"`
OrgPhone string `json:"OrgPhone"`
OrgURL string `json:"OrgURL"`

View file

@ -14,19 +14,6 @@ func TestUser(t *testing.T) {
assert.Equal(t, "passwd", form.Password)
}
func TestNewUser(t *testing.T) {
val := &User{UserName: "foobar", UserEmail: "test@test.com", Password: "passwd"}
form, err := NewUser(val)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "foobar", form.UserName)
assert.Equal(t, "test@test.com", form.UserEmail)
assert.Equal(t, "passwd", form.Password)
}
func TestUser_Name(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
form := &User{UserName: "", UserEmail: "test@test.com", Password: "passwd"}

View file

@ -9,7 +9,7 @@ const (
ErrNotFound
ErrFileNotFound
ErrFileTooLarge
ErrWrongFileType
ErrUnsupportedFormat
ErrOriginalsEmpty
ErrSelectionNotFound
ErrEntityNotFound
@ -99,7 +99,7 @@ var Messages = MessageMap{
ErrNotFound: gettext("Not found"),
ErrFileNotFound: gettext("File not found"),
ErrFileTooLarge: gettext("File too large"),
ErrWrongFileType: gettext("Wrong file type"),
ErrUnsupportedFormat: gettext("Unsupported format"),
ErrOriginalsEmpty: gettext("Originals folder is empty"),
ErrSelectionNotFound: gettext("Selection not found"),
ErrEntityNotFound: gettext("Entity not found"),

View file

@ -139,6 +139,6 @@ var DialectMySQL = Migrations{
ID: "20221015-100100",
Dialect: "mysql",
Stage: "pre",
Statements: []string{"ALTER TABLE files_sync CHANGE account_id service_id INT UNSIGNED NOT NULL;", "ALTER TABLE files_share CHANGE account_id service_id INT UNSIGNED NOT NULL;"},
Statements: []string{"ALTER IGNORE TABLE files_sync CHANGE account_id service_id INT UNSIGNED NOT NULL;", "ALTER IGNORE TABLE files_share CHANGE account_id service_id INT UNSIGNED NOT NULL;"},
},
}

View file

@ -1,2 +1,2 @@
ALTER TABLE files_sync CHANGE account_id service_id INT UNSIGNED NOT NULL;
ALTER TABLE files_share CHANGE account_id service_id INT UNSIGNED NOT NULL;
ALTER IGNORE TABLE files_sync CHANGE account_id service_id INT UNSIGNED NOT NULL;
ALTER IGNORE TABLE files_share CHANGE account_id service_id INT UNSIGNED NOT NULL;

View file

@ -18,10 +18,10 @@ func AutoTLS(conf *config.Config) (*autocert.Manager, error) {
return nil, fmt.Errorf("default site url does not use https")
} else if siteDomain = conf.SiteDomain(); !strings.Contains(siteDomain, ".") {
return nil, fmt.Errorf("no fully qualified site domain")
} else if tlsEmail = conf.AutoTLS(); tlsEmail == "" {
} else if tlsEmail = conf.TLSEmail(); tlsEmail == "" {
return nil, fmt.Errorf("automatic tls disabled")
} else if certDir = conf.CertsPath(); certDir == "" {
return nil, fmt.Errorf("certs path not found")
} else if certDir = conf.CertificatesPath(); certDir == "" {
return nil, fmt.Errorf("certificates path not found")
}
// Create Let's Encrypt cert manager.

View file

@ -76,7 +76,7 @@ func BasicAuth() gin.HandlerFunc {
clientIp := api.ClientIP(c)
// Check limit for failed auth requests (max. 10 per minute).
if limiter.Auth.Reject(clientIp) {
if limiter.Login.Reject(clientIp) {
limiter.Abort(c)
return
}
@ -89,7 +89,7 @@ func BasicAuth() gin.HandlerFunc {
// Username not found.
message := "account not found"
limiter.Auth.Reserve(clientIp)
limiter.Login.Reserve(clientIp)
event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else if !user.CanUseWebDAV() {
@ -102,7 +102,7 @@ func BasicAuth() gin.HandlerFunc {
// Wrong password.
message := "incorrect password"
limiter.Auth.Reserve(clientIp)
limiter.Login.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name))
event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message)
} else {

View file

@ -1,13 +0,0 @@
package limiter
import (
"time"
"golang.org/x/time/rate"
)
const DefaultAuthLimit = 10
const DefaultAuthInterval = time.Minute
// Auth limits failed authentication requests (one per minute).
var Auth = NewLimit(rate.Every(DefaultAuthInterval), DefaultAuthLimit)

View file

@ -1,60 +0,0 @@
package limiter
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuth(t *testing.T) {
clientIp := "192.0.2.42"
for i := 0; i < 9; i++ {
t.Logf("tokens now: %f", Auth.IP(clientIp).TokensAt(time.Now()))
assert.True(t, Auth.IP(clientIp).Allow())
}
assert.True(t, Auth.IP(clientIp).Allow())
assert.False(t, Auth.IP(clientIp).Allow())
assert.False(t, Auth.IP(clientIp).Allow())
assert.False(t, Auth.IP(clientIp).Allow())
t.Logf("tokens now: %f", Auth.IP(clientIp).TokensAt(time.Now()))
t.Logf("tokens +1min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute)))
t.Logf("tokens +2min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)))
t.Logf("tokens +3min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)))
t.Logf("tokens +4min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)))
t.Logf("tokens +5min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)))
t.Logf("tokens +10min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)))
t.Logf("tokens +15min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*15)))
t.Logf("tokens +20min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)))
assert.InEpsilon(t, 1, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*1)), 0.1)
assert.InEpsilon(t, 2, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)), 0.1)
assert.InEpsilon(t, 3, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)), 0.1)
assert.InEpsilon(t, 4, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)), 0.1)
assert.InEpsilon(t, 5, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)), 0.01)
for i := 0; i < 30; i++ {
assert.False(t, Auth.IP(clientIp).Allow())
}
assert.False(t, Auth.IP(clientIp).Allow())
t.Logf("tokens now: %f", Auth.IP(clientIp).TokensAt(time.Now()))
t.Logf("tokens +5min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)))
t.Logf("tokens +10min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)))
t.Logf("tokens +15min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*15)))
t.Logf("tokens +20min: %f", Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)))
assert.InEpsilon(t, 1, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*1)), 0.1)
assert.InEpsilon(t, 2, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)), 0.1)
assert.InEpsilon(t, 3, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)), 0.1)
assert.InEpsilon(t, 4, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)), 0.1)
assert.InEpsilon(t, 5, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)), 0.1)
assert.InEpsilon(t, 10, Auth.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)), 0.01)
}

View file

@ -0,0 +1,13 @@
package limiter
import (
"time"
"golang.org/x/time/rate"
)
const DefaultLoginLimit = 10
const DefaultLoginInterval = time.Minute
// Login limits failed authentication requests (one per minute).
var Login = NewLimit(rate.Every(DefaultLoginInterval), DefaultLoginLimit)

View file

@ -0,0 +1,60 @@
package limiter
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLogin(t *testing.T) {
clientIp := "192.0.2.42"
for i := 0; i < 9; i++ {
t.Logf("tokens now: %f", Login.IP(clientIp).TokensAt(time.Now()))
assert.True(t, Login.IP(clientIp).Allow())
}
assert.True(t, Login.IP(clientIp).Allow())
assert.False(t, Login.IP(clientIp).Allow())
assert.False(t, Login.IP(clientIp).Allow())
assert.False(t, Login.IP(clientIp).Allow())
t.Logf("tokens now: %f", Login.IP(clientIp).TokensAt(time.Now()))
t.Logf("tokens +1min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute)))
t.Logf("tokens +2min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)))
t.Logf("tokens +3min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)))
t.Logf("tokens +4min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)))
t.Logf("tokens +5min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)))
t.Logf("tokens +10min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)))
t.Logf("tokens +15min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*15)))
t.Logf("tokens +20min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)))
assert.InEpsilon(t, 1, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*1)), 0.1)
assert.InEpsilon(t, 2, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)), 0.1)
assert.InEpsilon(t, 3, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)), 0.1)
assert.InEpsilon(t, 4, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)), 0.1)
assert.InEpsilon(t, 5, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)), 0.1)
assert.InEpsilon(t, 10, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)), 0.1)
assert.InEpsilon(t, 10, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)), 0.01)
for i := 0; i < 30; i++ {
assert.False(t, Login.IP(clientIp).Allow())
}
assert.False(t, Login.IP(clientIp).Allow())
t.Logf("tokens now: %f", Login.IP(clientIp).TokensAt(time.Now()))
t.Logf("tokens +5min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)))
t.Logf("tokens +10min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)))
t.Logf("tokens +15min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*15)))
t.Logf("tokens +20min: %f", Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)))
assert.InEpsilon(t, 1, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*1)), 0.1)
assert.InEpsilon(t, 2, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*2)), 0.1)
assert.InEpsilon(t, 3, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*3)), 0.1)
assert.InEpsilon(t, 4, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*4)), 0.1)
assert.InEpsilon(t, 5, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*5)), 0.1)
assert.InEpsilon(t, 10, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*10)), 0.1)
assert.InEpsilon(t, 10, Login.IP(clientIp).TokensAt(time.Now().Add(time.Minute*20)), 0.01)
}

View file

@ -74,22 +74,21 @@ func Start(ctx context.Context, conf *config.Config) {
// Enable TLS?
if tlsManager, tlsErr = AutoTLS(conf); tlsErr == nil {
httpsRedirect = conf.HttpsRedirect()
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpsPort()),
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort()),
TLSConfig: tlsManager.TLSConfig(),
Handler: router,
}
log.Infof("server: starting in auto tls mode on %s [%s]", server.Addr, time.Since(start))
go StartAutoTLS(server, tlsManager, conf)
} else if httpsCert, privateKey := conf.TLS(); httpsCert != "" && privateKey != "" {
} else if publicCert, privateKey := conf.TLS(); publicCert != "" && privateKey != "" {
log.Infof("server: starting in manual tls mode")
server = &http.Server{
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpsPort()),
Addr: fmt.Sprintf("%s:%d", conf.HttpHost(), conf.HttpPort()),
Handler: router,
}
log.Infof("server: listening on %s [%s]", server.Addr, time.Since(start))
go StartTLS(server, httpsCert, privateKey)
go StartTLS(server, publicCert, privateKey)
} else {
log.Infof("server: %s", tlsErr)
server = &http.Server{

View file

@ -23,6 +23,20 @@ func UniqueNames(names []string) (result []string) {
return result
}
// AppendName appends a name to an existing name.
func AppendName(s, n string) string {
s = strings.TrimSpace(s)
n = strings.TrimSpace(n)
if s == "" {
return n
} else if s == n {
return s
}
return fmt.Sprintf("%s %s", s, n)
}
// JoinNames joins a list of names to be used in titles and descriptions.
func JoinNames(names []string, shorten bool) (result string) {
l := len(names)

51
pkg/txt/names_lists.go Normal file
View file

@ -0,0 +1,51 @@
package txt
func init() {
IsNameSuffix = make(map[string]bool, len(NameSuffixes))
for _, n := range NameSuffixes {
IsNameSuffix[n] = true
}
IsNameTitle = make(map[string]bool, len(NameTitles))
for _, n := range NameTitles {
IsNameTitle[n] = true
}
}
var IsNameSuffix map[string]bool
var IsNameTitle map[string]bool
var NameSuffixes = []string{"esq", "esquire", "jr", "jnr", "sr", "snr", "2", "ii", "iii", "iv",
"v", "clu", "chfc", "cfp", "md", "phd", "j.d.", "ll.m.", "m.d.", "d.o.", "d.c.",
"p.c.", "ph.d."}
var NameTitles = []string{"mr", "mrs", "ms", "miss", "dr", "herr", "monsieur", "hr", "frau",
"a v m", "admiraal", "admiral", "air cdre", "air commodore", "air marshal",
"air vice marshal", "alderman", "alhaji", "ambassador", "baron", "barones",
"brig", "brig gen", "brig general", "brigadier", "brigadier general",
"brother", "canon", "capt", "captain", "cardinal", "cdr", "chief", "cik", "cmdr",
"coach", "col", "colonel", "commandant", "commander", "commissioner",
"commodore", "comte", "comtessa", "congressman", "conseiller", "consul",
"conte", "contessa", "corporal", "councillor", "count", "countess",
"crown prince", "crown princess", "dame", "datin", "dato", "datuk",
"datuk seri", "deacon", "deaconess", "dean", "dhr", "dipl ing", "doctor",
"dott", "dott sa", "dr ing", "dra", "drs", "embajador", "embajadora", "en",
"encik", "eng", "eur ing", "exma sra", "exmo sr", "f o", "father",
"first lieutient", "first officer", "flt lieut", "flying officer", "fr",
"frau", "fraulein", "fru", "gen", "generaal", "general", "governor", "graaf",
"gravin", "group captain", "grp capt", "h e dr", "h h", "h m", "h r h", "hajah",
"haji", "hajim", "her highness", "her majesty", "herr", "high chief",
"his highness", "his holiness", "his majesty", "hon", "hr", "hra", "ing", "ir",
"jonkheer", "judge", "justice", "khun ying", "kolonel", "lady", "lcda", "lic",
"lieut", "lieut cdr", "lieut col", "lieut gen", "lord", "m", "m l", "m r",
"madame", "mademoiselle", "maj gen", "major", "master", "mevrouw", "miss",
"mlle", "mme", "monsieur", "monsignor", "mstr", "nti", "pastor",
"president", "prince", "princess", "princesse", "prinses", "prof",
"prof sir", "professor", "puan", "puan sri", "rabbi", "rear admiral", "rev",
"rev canon", "rev dr", "rev mother", "reverend", "rva", "senator", "sergeant",
"sheikh", "sheikha", "sig", "sig na", "sig ra", "sir", "sister", "sqn ldr", "sr",
"sr d", "sra", "srta", "sultan", "tan sri", "tan sri dato", "tengku", "teuku",
"than puying", "the hon dr", "the hon justice", "the hon miss", "the hon mr",
"the hon mrs", "the hon ms", "the hon sir", "the very rev", "toh puan", "tun",
"vice admiral", "viscount", "viscountess", "wg cdr"}

51
pkg/txt/names_parser.go Normal file
View file

@ -0,0 +1,51 @@
package txt
import (
"strings"
)
// Name represents the components of a full name.
type Name struct {
Title string
Given string
Middle string
Family string
Suffix string
Nick string
}
// ParseName parses a full name and returns the components.
func ParseName(full string) Name {
name := Name{}
name.Parse(full)
return name
}
// Parse tries to parse a full name.
func (name *Name) Parse(full string) {
if full == "" {
return
}
for _, w := range KeywordsRegexp.FindAllString(full, -1) {
w = strings.Trim(w, "- '")
if w == "" || len(w) < 2 && IsLatin(w) {
continue
}
l := strings.ToLower(w)
if IsNameTitle[l] {
name.Title = AppendName(name.Title, w)
} else if IsNameSuffix[l] {
name.Suffix = AppendName(name.Suffix, w)
} else if name.Given == "" {
name.Given = w
} else {
name.Family = AppendName(name.Family, w)
}
}
return
}

View file

@ -0,0 +1,15 @@
package txt
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseName(t *testing.T) {
t.Run("BillGates", func(t *testing.T) {
result := ParseName("William Henry Gates III")
t.Logf("Name: %#v", result)
assert.Equal(t, "William", result.Given)
})
}

View file

@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -e
sizes=(16 20 29 32 40 48 50 55 56 60 64 72 76 80 100 114 120 128 144 152 160 167 172 175 180 192 196 200 216 256 267 400 512 1024)
if [[ -z $1 ]] && [[ -z $2 ]]; then
echo "Please provide a source SVG and target PNG name" 1>&2
exit 1
elif [[ $1 ]] && [[ -z $2 ]]; then
mkdir -p "assets/static/icons/$1"
if [ -f "assets/static/icons/$1.svg" ]; then
echo "creating png icons from assets/static/icons/$1.svg..."
else
echo "assets/static/icons/$1.svg not found"
fi
for i in "${sizes[@]}"
do
rsvg-convert -a -w $i -h $i "assets/static/icons/$1.svg" > "assets/static/icons/$1/$i.png"
echo "assets/static/icons/$1/$i.png"
done
else
if [ -f "$1" ]; then
echo "creating png icons from $1..."
else
echo "$1 not found"
fi
for i in "${sizes[@]}"
do
rsvg-convert -a -w $i -h $i $1 > "$2-$i.png"
echo "$2-$i.png"
done
fi
echo "Done"