Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
a5f2c5e109
commit
6e74f16a77
131 changed files with 1646 additions and 2974 deletions
|
@ -28,19 +28,11 @@
|
|||
el.classList.add("day-" + new Date().getDay());
|
||||
el.style.setProperty("display", "block");
|
||||
</script>
|
||||
<div class="loading-animation">
|
||||
<div class="loading-circle"></div>
|
||||
</div>
|
||||
<div class="loading-logo">
|
||||
{{if eq .config.AppIcon "crisp"}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 264"><defs><linearGradient id="a" x1="45.04" y1="231.72" x2="231.72" y2="45.04" gradientUnits="userSpaceOnUse" gradientTransform="translate(-6.38 -6.38)"><stop offset="0" stop-color="#fff"/><stop offset="0" stop-color="#9afbfe"/><stop offset="1" stop-color="#ffb3e0"/></linearGradient></defs><circle cx="132" cy="132" r="132" style="fill:url(#a)"/><path d="m223.19 175.51-4 24.19M40.91 176.5l14.81 14m95.76-137.65L55.62 190.31a.09.09 0 0 0 .07.15l163.41 9.37a.09.09 0 0 0 .09-.13L151.62 52.87a.1.1 0 0 0-.14-.02zm-19.74-13.29L40.8 176.31a.13.13 0 0 0 .11.19l182.18-.8a.12.12 0 0 0 .1-.19L131.95 39.56a.12.12 0 0 0-.21 0zm.11-.16 19.77 13.32" style="fill:none;stroke:#1d1d1b;stroke-width:5px;stroke-miterlimit:10"/></svg>
|
||||
{{else if eq .config.AppIcon "mint"}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 264"><defs><linearGradient id="a" x1="45.04" y1="231.72" x2="231.72" y2="45.04" gradientUnits="userSpaceOnUse" gradientTransform="translate(-6.38 -6.38)"><stop offset="0" stop-color="#fff"/><stop offset="0" stop-color="#c2fde4"/><stop offset="1" stop-color="#cdc6e9"/></linearGradient></defs><circle cx="132" cy="132" r="132" style="fill:url(#a)"/><path d="m223.19 175.51-4 24.19M40.91 176.5l14.81 14m95.76-137.65L55.62 190.31a.09.09 0 0 0 .07.15l163.41 9.37a.09.09 0 0 0 .09-.13L151.62 52.87a.1.1 0 0 0-.14-.02zm-19.74-13.29L40.8 176.31a.13.13 0 0 0 .11.19l182.18-.8a.12.12 0 0 0 .1-.19L131.95 39.56a.12.12 0 0 0-.21 0zm.11-.16 19.77 13.32" style="fill:none;stroke:#1d1d1b;stroke-miterlimit:10;stroke-width:5px"/></svg>
|
||||
{{else if eq .config.AppIcon "bold"}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 264"><defs><linearGradient id="a" x1="45.04" y1="231.72" x2="231.72" y2="45.04" gradientUnits="userSpaceOnUse" gradientTransform="translate(-6.38 -6.38)"><stop offset="0" stop-color="#fff"/><stop offset="0" stop-color="#99e6ff"/><stop offset="1" stop-color="#c299ff"/></linearGradient></defs><circle cx="132" cy="132" r="132" style="fill:url(#a)"/><path data-name="Logo Pfad" d="m223.19 175.51-4 24.19M40.91 176.5l14.81 14m95.76-137.65L55.62 190.31a.09.09 0 0 0 .07.15l163.41 9.37a.09.09 0 0 0 .09-.13L151.62 52.87a.1.1 0 0 0-.14-.02zm-19.74-13.29L40.8 176.31a.13.13 0 0 0 .11.19l182.18-.8a.12.12 0 0 0 .1-.19L131.95 39.56a.12.12 0 0 0-.21 0zm.11-.16 19.77 13.32" style="fill:none;stroke:#1d1d1b;stroke-miterlimit:10;stroke-width:7px"/></svg>
|
||||
{{else}}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 264"><defs><linearGradient id="a" x1="45.04" y1="231.72" x2="231.72" y2="45.04" gradientUnits="userSpaceOnUse" gradientTransform="translate(-6.38 -6.38)"><stop offset="0" stop-color="#fff"/><stop offset="0" stop-color="#b8edff"/><stop offset="1" stop-color="#d4b8ff"/></linearGradient></defs><circle cx="132" cy="132" r="132" style="fill:url(#a)"/><path data-name="Logo Pfad" d="m223.19 175.51-4 24.19M40.91 176.5l14.81 14m95.76-137.65L55.62 190.31a.09.09 0 0 0 .07.15l163.41 9.37a.09.09 0 0 0 .09-.13L151.62 52.87a.1.1 0 0 0-.14-.02zm-19.74-13.29L40.8 176.31a.13.13 0 0 0 .11.19l182.18-.8a.12.12 0 0 0 .1-.19L131.95 39.56a.12.12 0 0 0-.21 0zm.11-.16 19.77 13.32" style="fill:none;stroke:#1d1d1b;stroke-miterlimit:10;stroke-width:6px;shape-rendering:geometricPrecision"/></svg>
|
||||
{{end}}
|
||||
<div class="splash-center">
|
||||
<div class="splash-logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 264 264"><defs><linearGradient id="a" x1="45.04" y1="231.72" x2="231.72" y2="45.04" gradientUnits="userSpaceOnUse" gradientTransform="translate(-6.38 -6.38)"><stop offset="0" stop-color="#fff"/><stop offset="0" stop-color="#b8edff"/><stop offset="1" stop-color="#d4b8ff"/></linearGradient></defs><circle cx="132" cy="132" r="132" style="fill:url(#a)"/><path data-name="Logo Pfad" d="m223.19 175.51-4 24.19M40.91 176.5l14.81 14m95.76-137.65L55.62 190.31a.09.09 0 0 0 .07.15l163.41 9.37a.09.09 0 0 0 .09-.13L151.62 52.87a.1.1 0 0 0-.14-.02zm-19.74-13.29L40.8 176.31a.13.13 0 0 0 .11.19l182.18-.8a.12.12 0 0 0 .1-.19L131.95 39.56a.12.12 0 0 0-.21 0zm.11-.16 19.77 13.32" style="fill:none;stroke:#1d1d1b;stroke-miterlimit:10;stroke-width:6px;shape-rendering:geometricPrecision"/></svg>
|
||||
</div>
|
||||
<progress id="progress" max="100"></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -27,10 +27,11 @@
|
|||
<link rel="manifest" href="{{ .config.ManifestUri }}" crossorigin="use-credentials">
|
||||
|
||||
<script>
|
||||
window.__SHARED__ = {{ .shared }};
|
||||
window.__CONFIG__ = {{ .config }};
|
||||
</script>
|
||||
</head>
|
||||
<body class="shared {{ .config.Flags }} nojs">
|
||||
<body class="{{ .config.Flags }} nojs">
|
||||
|
||||
{{template "app.tmpl" .}}
|
||||
|
||||
|
|
|
@ -54,8 +54,11 @@ import "common/maptiler-lang";
|
|||
import { T, Mount } from "common/vm";
|
||||
import * as offline from "@lcdp/offline-plugin/runtime";
|
||||
|
||||
config.progress(50);
|
||||
|
||||
config.load().finally(() => {
|
||||
// Initialize helpers.
|
||||
// Initialize libs and framework.
|
||||
config.progress(66);
|
||||
const viewer = new Viewer();
|
||||
const isPublic = config.get("public");
|
||||
const isMobile =
|
||||
|
@ -204,7 +207,6 @@ config.load().finally(() => {
|
|||
|
||||
// Start application.
|
||||
Mount(Vue, PhotoPrism, router);
|
||||
|
||||
if (config.baseUri === "") {
|
||||
offline.install();
|
||||
}
|
||||
|
|
|
@ -131,7 +131,7 @@ export default [
|
|||
name: "moment",
|
||||
path: "/moments/:uid/:slug",
|
||||
component: AlbumPhotos,
|
||||
meta: { title: $gettext("Moments"), auth: true },
|
||||
meta: { collName: "Moments", collRoute: "moments", auth: true },
|
||||
},
|
||||
{
|
||||
name: "albums",
|
||||
|
@ -144,7 +144,7 @@ export default [
|
|||
name: "album",
|
||||
path: "/albums/:uid/:slug",
|
||||
component: AlbumPhotos,
|
||||
meta: { title: $gettext("Albums"), auth: true },
|
||||
meta: { collName: "Albums", collRoute: "albums", auth: true },
|
||||
},
|
||||
{
|
||||
name: "calendar",
|
||||
|
@ -157,7 +157,7 @@ export default [
|
|||
name: "month",
|
||||
path: "/calendar/:uid/:slug",
|
||||
component: AlbumPhotos,
|
||||
meta: { title: $gettext("Calendar"), auth: true },
|
||||
meta: { collName: "Calendar", collRoute: "calendar", auth: true },
|
||||
},
|
||||
{
|
||||
name: "folders",
|
||||
|
@ -170,7 +170,7 @@ export default [
|
|||
name: "folder",
|
||||
path: "/folders/:uid/:slug",
|
||||
component: AlbumPhotos,
|
||||
meta: { title: $gettext("Folders"), auth: true },
|
||||
meta: { collName: "Folders", collRoute: "folders", auth: true },
|
||||
},
|
||||
{
|
||||
name: "unsorted",
|
||||
|
@ -250,7 +250,7 @@ export default [
|
|||
name: "state",
|
||||
path: "/states/:uid/:slug",
|
||||
component: AlbumPhotos,
|
||||
meta: { title: $gettext("Places"), auth: true },
|
||||
meta: { collName: "Places", collRoute: "states", auth: true },
|
||||
},
|
||||
{
|
||||
name: "files",
|
||||
|
|
|
@ -2,6 +2,6 @@ import Config from "common/config";
|
|||
import Session from "common/session";
|
||||
|
||||
export const config = new Config(window.localStorage, window.__CONFIG__);
|
||||
export const session = new Session(window.localStorage, config);
|
||||
export const session = new Session(window.localStorage, config, window["__SHARED__"]);
|
||||
|
||||
export default session;
|
||||
|
|
|
@ -29,8 +29,7 @@ import * as themes from "options/themes";
|
|||
import translations from "locales/translations.json";
|
||||
import { Languages } from "options/options";
|
||||
import { Photo } from "model/photo";
|
||||
import { onSetTheme } from "common/hooks";
|
||||
import { onInit } from "common/hooks";
|
||||
import { onInit, onSetTheme } from "common/hooks";
|
||||
|
||||
onInit();
|
||||
|
||||
|
@ -584,4 +583,11 @@ export default class Config {
|
|||
getSiteDescription() {
|
||||
return this.values.siteDescription ? this.values.siteDescription : this.values.siteCaption;
|
||||
}
|
||||
|
||||
progress(p) {
|
||||
const el = document.getElementById("progress");
|
||||
if (el) {
|
||||
el.value = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ export default class Session {
|
|||
* @param {Storage} storage
|
||||
* @param {Config} config
|
||||
*/
|
||||
constructor(storage, config) {
|
||||
constructor(storage, config, shared) {
|
||||
this.storage_key = "session_storage";
|
||||
this.auth = false;
|
||||
this.config = config;
|
||||
|
@ -77,9 +77,23 @@ export default class Session {
|
|||
});
|
||||
|
||||
// Say hello.
|
||||
this.refresh().then(() => {
|
||||
this.sendClientInfo();
|
||||
});
|
||||
if (shared && shared.token) {
|
||||
this.config.progress(80);
|
||||
this.redeemToken(shared.token).finally(() => {
|
||||
this.config.progress(99);
|
||||
if (shared.uri) {
|
||||
window.location = shared.uri;
|
||||
} else {
|
||||
window.location = this.config.baseUri + "/";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.config.progress(80);
|
||||
this.refresh().then(() => {
|
||||
this.config.progress(90);
|
||||
this.sendClientInfo();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useSessionStorage() {
|
||||
|
|
|
@ -4,6 +4,12 @@
|
|||
@submit.prevent="updateQuery()">
|
||||
<v-toolbar flat :dense="$vuetify.breakpoint.smAndDown" class="page-toolbar" color="secondary">
|
||||
<v-toolbar-title :title="album.Title">
|
||||
<span class="hidden-xs-only">
|
||||
<router-link :to="{ name: collRoute }">
|
||||
{{ $gettext(collName) }}
|
||||
</router-link>
|
||||
<v-icon>{{ navIcon }}</v-icon>
|
||||
</span>
|
||||
{{ album.Title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
|
@ -118,6 +124,9 @@ export default {
|
|||
experimental: this.$config.get("experimental"),
|
||||
isFullScreen: !!document.fullscreenElement,
|
||||
categories: this.$config.albumCategories(),
|
||||
collName: this.$route.meta && this.$route.meta.collName ? this.$route.meta.collName : this.$gettext("Albums"),
|
||||
collRoute: this.$route.meta && this.$route.meta.collRoute ? this.$route.meta.collRoute : "albums",
|
||||
navIcon: this.$rtl ? 'navigate_before' : 'navigate_next',
|
||||
searchExpanded: false,
|
||||
options: {
|
||||
'views': [
|
||||
|
|
|
@ -292,6 +292,7 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<template v-if="canSearchPlaces">
|
||||
<v-list-tile v-if="isMini" v-show="canSearchPlaces && $config.feature('places')" :to="{ name: 'places' }" class="nav-places"
|
||||
@click.stop="">
|
||||
<v-list-tile-action :title="$gettext('Places')">
|
||||
|
@ -328,6 +329,20 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
</v-list-group>
|
||||
</template>
|
||||
<v-list-tile v-else v-show="$config.feature('places')" to="/states" class="nav-states" @click.stop="">
|
||||
<v-list-tile-action :title="$gettext('States')">
|
||||
<v-icon>map</v-icon>
|
||||
</v-list-tile-action>
|
||||
|
||||
<v-list-tile-content>
|
||||
<v-list-tile-title class="p-flex-menuitem" @click.stop="">
|
||||
<translate key="States">States</translate>
|
||||
<span v-show="config.count.states > 0"
|
||||
:class="`nav-count ${rtl ? '--rtl' : ''}`">{{ config.count.states | abbreviateCount }}</span>
|
||||
</v-list-tile-title>
|
||||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile v-show="$config.feature('labels')" to="/labels" class="nav-labels" @click.stop="">
|
||||
<v-list-tile-action :title="$gettext('Labels')">
|
||||
|
@ -424,7 +439,7 @@
|
|||
</v-list-group>
|
||||
|
||||
<template v-if="!config.disable.settings">
|
||||
<v-list-tile v-if="isMini" to="/settings" class="nav-settings" @click.stop="">
|
||||
<v-list-tile v-if="isMini" v-show="$config.feature('settings')" to="/settings" class="nav-settings" @click.stop="">
|
||||
<v-list-tile-action :title="$gettext('Settings')">
|
||||
<v-icon>settings</v-icon>
|
||||
</v-list-tile-action>
|
||||
|
@ -436,7 +451,7 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-group v-else prepend-icon="settings" no-action>
|
||||
<v-list-group v-else v-show="$config.feature('settings')" prepend-icon="settings" no-action>
|
||||
<template #activator>
|
||||
<v-list-tile to="/settings" class="nav-settings" @click.stop="">
|
||||
<v-list-tile-content>
|
||||
|
@ -503,7 +518,7 @@
|
|||
</v-list-tile-content>
|
||||
</v-list-tile>
|
||||
|
||||
<v-list-tile v-show="auth && !isPublic" to="/settings/account" class="p-profile">
|
||||
<v-list-tile v-show="auth && !isPublic && $config.feature('settings')" to="/settings/account" class="p-profile">
|
||||
<v-list-tile-avatar color="grey" size="36">
|
||||
<span class="white--text headline">{{ !!displayName ? displayName[0].toUpperCase() : "E" }}</span>
|
||||
</v-list-tile-avatar>
|
||||
|
@ -553,7 +568,7 @@
|
|||
:to="{ name: 'library_logs' }" :title="$gettext('Logs')" class="menu-action nav-logs">
|
||||
<v-icon>feed</v-icon>
|
||||
</router-link>
|
||||
<router-link v-if="auth && !config.disable.settings && !routeName('settings')" to="/settings"
|
||||
<router-link v-if="auth && $config.feature('settings') && !routeName('settings')" to="/settings"
|
||||
:title="$gettext('Settings')" class="menu-action nav-settings">
|
||||
<v-icon>settings</v-icon>
|
||||
</router-link>
|
||||
|
|
|
@ -161,7 +161,7 @@ nav .v-list__tile__title.title {
|
|||
/* Mobile Menu */
|
||||
|
||||
#photoprism #p-navigation .mobile-menu-trigger {
|
||||
width: 48px;
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
@ -43,6 +43,47 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
#photoprism .splash-center {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
#photoprism .splash-center .splash-logo {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
left: 50%;
|
||||
right: 50%;
|
||||
margin: auto auto 42px auto;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
#splash-animation .splash-logo svg {
|
||||
-webkit-animation: hue 4s infinite linear;
|
||||
-moz-animation: hue 4s infinite linear;
|
||||
animation: hue 4s infinite linear;
|
||||
}
|
||||
|
||||
#photoprism .splash-center #progress {
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
#photoprism .splash-center .splash-logo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
#photoprism .splash-center #progress {
|
||||
width: 225px;
|
||||
}
|
||||
}
|
||||
|
||||
#photoprism div.loading-animation,
|
||||
#photoprism div.loading-logo,
|
||||
#photoprism div.loading-logo svg {
|
||||
|
|
|
@ -104,8 +104,8 @@ export class Rest extends Model {
|
|||
Password: password ? password : "",
|
||||
Expires: expires ? expires : 0,
|
||||
Slug: this.getSlug(),
|
||||
CanEdit: false,
|
||||
CanComment: false,
|
||||
Comment: "",
|
||||
Perm: 0,
|
||||
}).then((resp) => Promise.resolve(new Link(resp.data)));
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
@click:clear="() => {updateQuery({'q': ''})}"
|
||||
></v-text-field>
|
||||
|
||||
<v-overflow-btn :value="filter.category"
|
||||
<v-overflow-btn v-if="canManage" :value="filter.category"
|
||||
solo hide-details single-line
|
||||
:label="$gettext('Category')"
|
||||
color="secondary-dark"
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</router-link>
|
||||
|
||||
<router-link v-for="(item, index) in breadcrumbs" :key="index" :to="item.path">
|
||||
<v-icon>navigate_next</v-icon>
|
||||
<v-icon>{{ navIcon }}</v-icon>
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</v-toolbar-title>
|
||||
|
@ -137,6 +137,7 @@ export default {
|
|||
|
||||
return {
|
||||
config: this.$config.values,
|
||||
navIcon: this.$rtl ? 'navigate_before' : 'navigate_next',
|
||||
subscriptions: [],
|
||||
listen: false,
|
||||
dirty: false,
|
||||
|
|
|
@ -414,15 +414,15 @@ export default {
|
|||
});
|
||||
},
|
||||
onChange() {
|
||||
const reload = this.settings.changed("ui", "language");
|
||||
const locale = this.settings.changed("ui", "language");
|
||||
|
||||
if (reload) {
|
||||
if (locale) {
|
||||
this.busy = true;
|
||||
}
|
||||
|
||||
this.settings.save().then(() => {
|
||||
this.$config.setSettings(this.settings);
|
||||
if (reload) {
|
||||
if (locale) {
|
||||
this.$notify.info(this.$gettext("Reloading…"));
|
||||
this.$notify.blockUI();
|
||||
setTimeout(() => window.location.reload(), 100);
|
||||
|
|
|
@ -25,173 +25,4 @@ Additional information can be found in our Developer Guide:
|
|||
|
||||
import "core-js/stable";
|
||||
import "regenerator-runtime/runtime";
|
||||
import "common/navigation";
|
||||
import Api from "common/api";
|
||||
import Notify from "common/notify";
|
||||
import Scrollbar from "common/scrollbar";
|
||||
import Clipboard from "common/clipboard";
|
||||
import Components from "share/components";
|
||||
import icons from "component/icons";
|
||||
import Dialogs from "dialog/dialogs";
|
||||
import Event from "pubsub-js";
|
||||
import GetTextPlugin from "vue-gettext";
|
||||
import Log from "common/log";
|
||||
import PhotoPrism from "share.vue";
|
||||
import Router from "vue-router";
|
||||
import Routes from "share/routes";
|
||||
import { config, session } from "app/session";
|
||||
import { Settings } from "luxon";
|
||||
import Socket from "common/websocket";
|
||||
import Viewer from "common/viewer";
|
||||
import Vue from "vue";
|
||||
import Vuetify from "vuetify";
|
||||
import VueLuxon from "vue-luxon";
|
||||
import VueFilters from "vue2-filters";
|
||||
import VueFullscreen from "vue-fullscreen";
|
||||
import VueInfiniteScroll from "vue-infinite-scroll";
|
||||
import Hls from "hls.js";
|
||||
import { Mount, T } from "common/vm";
|
||||
import * as options from "./options/options";
|
||||
|
||||
config.load().finally(() => {
|
||||
// Initialize helpers.
|
||||
const viewer = new Viewer();
|
||||
const isPublic = config.get("public");
|
||||
const isMobile =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2);
|
||||
|
||||
// Initialize language and detect alignment.
|
||||
Vue.config.language = config.getLanguage();
|
||||
Settings.defaultLocale = Vue.config.language.substring(0, 2);
|
||||
const languages = options.Languages();
|
||||
const rtl = languages.some((lang) => lang.value === Vue.config.language && lang.rtl);
|
||||
|
||||
// Get initial theme colors from config.
|
||||
const theme = config.theme.colors;
|
||||
|
||||
// HTTP Live Streaming (video support)
|
||||
window.Hls = Hls;
|
||||
|
||||
// Assign helpers to VueJS prototype.
|
||||
Vue.prototype.$event = Event;
|
||||
Vue.prototype.$notify = Notify;
|
||||
Vue.prototype.$scrollbar = Scrollbar;
|
||||
Vue.prototype.$viewer = viewer;
|
||||
Vue.prototype.$session = session;
|
||||
Vue.prototype.$api = Api;
|
||||
Vue.prototype.$log = Log;
|
||||
Vue.prototype.$socket = Socket;
|
||||
Vue.prototype.$config = config;
|
||||
Vue.prototype.$clipboard = Clipboard;
|
||||
Vue.prototype.$isMobile = isMobile;
|
||||
Vue.prototype.$rtl = rtl;
|
||||
|
||||
// Register Vuetify.
|
||||
Vue.use(Vuetify, { rtl, icons, theme });
|
||||
|
||||
// Register other VueJS plugins.
|
||||
Vue.use(GetTextPlugin, {
|
||||
translations: config.translations,
|
||||
silent: true, // !config.values.debug,
|
||||
defaultLanguage: Vue.config.language,
|
||||
autoAddKeyAttributes: true,
|
||||
});
|
||||
|
||||
Vue.use(VueLuxon);
|
||||
Vue.use(VueInfiniteScroll);
|
||||
Vue.use(VueFullscreen);
|
||||
Vue.use(VueFilters);
|
||||
Vue.use(Components);
|
||||
Vue.use(Dialogs);
|
||||
Vue.use(Router);
|
||||
|
||||
// Configure client-side routing.
|
||||
const router = new Router({
|
||||
routes: Routes,
|
||||
mode: "history",
|
||||
base: config.baseUri + "/",
|
||||
saveScrollPosition: true,
|
||||
scrollBehavior: (to, from, savedPosition) => {
|
||||
if (savedPosition) {
|
||||
return new Promise((resolve) => {
|
||||
Notify.ajaxWait().then(() => {
|
||||
setTimeout(() => {
|
||||
resolve(savedPosition);
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (document.querySelector(".v-dialog--active.v-dialog--fullscreen")) {
|
||||
// Disable back button in full-screen viewers and editors.
|
||||
next(false);
|
||||
} else if (
|
||||
to.matched.some((record) => record.meta.settings) &&
|
||||
config.values.disable.settings
|
||||
) {
|
||||
next({ name: "home" });
|
||||
} else if (to.matched.some((record) => record.meta.admin)) {
|
||||
if (isPublic || session.isAdmin()) {
|
||||
next();
|
||||
} else {
|
||||
next({
|
||||
name: "login",
|
||||
params: { nextUrl: to.fullPath },
|
||||
});
|
||||
}
|
||||
} else if (to.matched.some((record) => record.meta.auth)) {
|
||||
if (isPublic || session.isUser()) {
|
||||
next();
|
||||
} else {
|
||||
next({
|
||||
name: "login",
|
||||
params: { nextUrl: to.fullPath },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
const t = to.meta["title"] ? to.meta["title"] : "";
|
||||
|
||||
if (t !== "" && config.values.siteTitle !== t && config.values.name !== t) {
|
||||
config.page.title = T(t);
|
||||
|
||||
if (config.page.title.startsWith(config.values.siteTitle)) {
|
||||
window.document.title = config.page.title;
|
||||
} else if (config.page.title === "") {
|
||||
window.document.title = config.values.siteTitle;
|
||||
} else {
|
||||
window.document.title = config.page.title + " – " + config.values.siteTitle;
|
||||
}
|
||||
} else {
|
||||
config.page.title = config.values.name;
|
||||
|
||||
if (!config.values.sponsor) {
|
||||
window.document.title = config.values.name;
|
||||
} else if (config.values.siteCaption === "") {
|
||||
window.document.title = config.values.siteTitle;
|
||||
} else {
|
||||
window.document.title = config.values.siteCaption;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isMobile) {
|
||||
document.body.classList.add("mobile");
|
||||
} else {
|
||||
// Pull client config every 10 minutes in case push fails (except on mobile to save battery).
|
||||
setInterval(() => config.update(), 600000);
|
||||
}
|
||||
|
||||
// Start application.
|
||||
Mount(Vue, PhotoPrism, router);
|
||||
});
|
||||
import "app/session";
|
||||
|
|
|
@ -1,106 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-container v-if="selection.length > 0" fluid class="pa-0">
|
||||
<v-speed-dial
|
||||
id="t-clipboard" v-model="expanded" fixed
|
||||
bottom
|
||||
right
|
||||
direction="top"
|
||||
transition="slide-y-reverse-transition"
|
||||
class="p-clipboard p-album-clipboard"
|
||||
>
|
||||
<template #activator>
|
||||
<v-btn
|
||||
fab dark
|
||||
color="accent darken-2"
|
||||
class="action-menu"
|
||||
>
|
||||
<v-icon v-if="selection.length === 0">menu</v-icon>
|
||||
<span v-else class="count-clipboard">{{ selection.length }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-btn
|
||||
fab dark small
|
||||
:title="$gettext('Download')"
|
||||
color="download"
|
||||
class="action-download"
|
||||
:disabled="selection.length !== 1 || !$config.feature('download')"
|
||||
@click.stop="download()"
|
||||
>
|
||||
<v-icon>get_app</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
fab dark small
|
||||
color="accent"
|
||||
class="action-clear"
|
||||
@click.stop="clearClipboard()"
|
||||
>
|
||||
<v-icon>clear</v-icon>
|
||||
</v-btn>
|
||||
</v-speed-dial>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Notify from "common/notify";
|
||||
import Album from "model/album";
|
||||
import download from "common/download";
|
||||
|
||||
export default {
|
||||
name: 'PAlbumClipboard',
|
||||
props: {
|
||||
selection: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
refresh: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
clearSelection: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
context: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
model: new Album(),
|
||||
dialog: {
|
||||
delete: false,
|
||||
album: false,
|
||||
edit: false,
|
||||
share: false,
|
||||
upload: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clearClipboard() {
|
||||
this.clearSelection();
|
||||
this.expanded = false;
|
||||
},
|
||||
download() {
|
||||
if (this.selection.length !== 1) {
|
||||
Notify.error(this.$gettext("You can only download one album"));
|
||||
return;
|
||||
}
|
||||
|
||||
Notify.success(this.$gettext("Downloading…"));
|
||||
|
||||
this.onDownload(`${this.$config.apiUri}/albums/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
|
||||
|
||||
this.expanded = false;
|
||||
},
|
||||
onDownload(path) {
|
||||
download(path, "photoprism-album.zip");
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -1,572 +0,0 @@
|
|||
<template>
|
||||
<div v-infinite-scroll="loadMore" class="p-page p-page-albums" style="user-select: none"
|
||||
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
|
||||
:infinite-scroll-listen-for-event="'scrollRefresh'">
|
||||
<v-toolbar flat color="secondary" :dense="$vuetify.breakpoint.smAndDown">
|
||||
<v-toolbar-title>
|
||||
<translate>Albums</translate>
|
||||
</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-container v-if="loading" fluid class="pa-4">
|
||||
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
|
||||
</v-container>
|
||||
<v-container v-else fluid class="pa-0">
|
||||
<p-scroll-top></p-scroll-top>
|
||||
|
||||
<p-album-clipboard :refresh="refresh" :selection="selection"
|
||||
:clear-selection="clearSelection" :context="context"></p-album-clipboard>
|
||||
|
||||
<v-container grid-list-xs fluid class="pa-2">
|
||||
<v-alert
|
||||
:value="results.length === 0"
|
||||
color="secondary-dark" icon="bookmark" class="no-results ma-2 opacity-70" outline
|
||||
>
|
||||
<h3 class="body-2 ma-0 pa-0">
|
||||
<translate>No albums found</translate>
|
||||
</h3>
|
||||
<p class="body-1 mt-2 mb-0 pa-0">
|
||||
<translate>Try again using other filters or keywords.</translate>
|
||||
</p>
|
||||
</v-alert>
|
||||
<v-layout row wrap class="search-results album-results cards-view" :class="{'select-results': selection.length > 0}">
|
||||
<v-flex
|
||||
v-for="(album, index) in results"
|
||||
:key="album.UID"
|
||||
xs6 sm4 md3 xlg2 xxl1 d-flex
|
||||
>
|
||||
<v-card tile
|
||||
:data-uid="album.UID"
|
||||
style="user-select: none"
|
||||
class="result accent lighten-3"
|
||||
:class="album.classes(selection.includes(album.UID))"
|
||||
:to="album.route(view)"
|
||||
@contextmenu.stop="onContextMenu($event, index)"
|
||||
>
|
||||
<div class="card-background accent lighten-3" style="user-select: none"></div>
|
||||
<v-img
|
||||
:src="album.thumbnailUrl('tile_500')"
|
||||
:alt="album.Title"
|
||||
:transition="false"
|
||||
aspect-ratio="1"
|
||||
style="user-select: none"
|
||||
class="accent lighten-2 clickable"
|
||||
@touchstart.passive="input.touchStart($event, index)"
|
||||
@touchend.stop.prevent="onClick($event, index)"
|
||||
@mousedown.stop.prevent="input.mouseDown($event, index)"
|
||||
@click.stop.prevent="onClick($event, index)"
|
||||
>
|
||||
<v-btn :ripple="false"
|
||||
icon flat absolute
|
||||
class="input-select"
|
||||
@touchstart.stop.prevent="input.touchStart($event, index)"
|
||||
@touchend.stop.prevent="onSelect($event, index)"
|
||||
@touchmove.stop.prevent
|
||||
@click.stop.prevent="onSelect($event, index)">
|
||||
<v-icon color="white" class="select-on">check_circle</v-icon>
|
||||
<v-icon color="white" class="select-off">radio_button_off</v-icon>
|
||||
</v-btn>
|
||||
</v-img>
|
||||
|
||||
<v-card-title primary-title class="pl-3 pt-3 pr-3 pb-2 card-details" style="user-select: none;">
|
||||
<div>
|
||||
<h3 v-if="album.Type !== 'month'" class="body-2 mb-0" :title="album.Title">
|
||||
{{ album.Title | truncate(80) }}
|
||||
</h3>
|
||||
<h3 v-else class="body-2 mb-0">
|
||||
{{ album.getDateString() | capitalize }}
|
||||
</h3>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pb-2 pt-0 card-details">
|
||||
<div v-if="album.Description" class="caption mb-2" :title="$gettext('Description')">
|
||||
{{ album.Description }}
|
||||
</div>
|
||||
<div v-else class="caption mb-2">
|
||||
<translate>Shared with you.</translate>
|
||||
</div>
|
||||
<div v-if="album.Category !== ''" class="caption mb-2 d-inline-block">
|
||||
<button @click.stop="">
|
||||
<v-icon size="14">local_offer</v-icon>
|
||||
{{ album.Category }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="album.getLocation() !== ''" class="caption mb-2 d-inline-block">
|
||||
<button @click.stop="">
|
||||
<v-icon size="14">location_on</v-icon>
|
||||
{{ album.getLocation() }}
|
||||
</button>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Album from "model/album";
|
||||
import {DateTime} from "luxon";
|
||||
import Event from "pubsub-js";
|
||||
import RestModel from "model/rest";
|
||||
import {MaxItems} from "common/clipboard";
|
||||
import Notify from "common/notify";
|
||||
import {Input, InputInvalid, ClickShort, ClickLong} from "common/input";
|
||||
|
||||
export default {
|
||||
name: 'PPageAlbums',
|
||||
props: {
|
||||
staticFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
view: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const query = this.$route.query;
|
||||
const routeName = this.$route.name;
|
||||
const q = query["q"] ? query["q"] : "";
|
||||
const category = query["category"] ? query["category"] : "";
|
||||
|
||||
let categories = [{"value": "", "text": this.$gettext("All Categories")}];
|
||||
|
||||
if (this.$config.albumCategories().length > 0) {
|
||||
categories = categories.concat(this.$config.albumCategories().map(cat => {
|
||||
return {"value": cat, "text": cat};
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
site: this.$config.page,
|
||||
categories: categories,
|
||||
subscriptions: [],
|
||||
listen: false,
|
||||
dirty: false,
|
||||
results: [],
|
||||
loading: true,
|
||||
scrollDisabled: true,
|
||||
scrollDistance: window.innerHeight*2,
|
||||
batchSize: Album.batchSize(),
|
||||
offset: 0,
|
||||
page: 0,
|
||||
selection: [],
|
||||
settings: {},
|
||||
filter: {q, category},
|
||||
lastFilter: {},
|
||||
routeName: routeName,
|
||||
titleRule: v => v.length <= this.$config.get('clip') || this.$gettext("Title too long"),
|
||||
input: new Input(),
|
||||
lastId: "",
|
||||
model: new Album(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
context: function () {
|
||||
if (!this.staticFilter) {
|
||||
return "album";
|
||||
}
|
||||
|
||||
if (this.staticFilter.type) {
|
||||
return this.staticFilter.type;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route'() {
|
||||
const query = this.$route.query;
|
||||
|
||||
this.filter.q = query["q"] ? query["q"] : "";
|
||||
this.filter.category = query["category"] ? query["category"] : "";
|
||||
this.lastFilter = {};
|
||||
this.routeName = this.$route.name;
|
||||
this.search();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const token = this.$route.params.token;
|
||||
|
||||
if (this.$session.hasToken(token)) {
|
||||
this.search();
|
||||
} else {
|
||||
this.$session.redeemToken(token).then(() => {
|
||||
this.search();
|
||||
});
|
||||
}
|
||||
|
||||
this.subscriptions.push(Event.subscribe("albums", (ev, data) => this.onUpdate(ev, data)));
|
||||
|
||||
this.subscriptions.push(Event.subscribe("touchmove.top", () => this.refresh()));
|
||||
this.subscriptions.push(Event.subscribe("touchmove.bottom", () => this.loadMore()));
|
||||
},
|
||||
destroyed() {
|
||||
for (let i = 0; i < this.subscriptions.length; i++) {
|
||||
Event.unsubscribe(this.subscriptions[i]);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchCount() {
|
||||
const offset = parseInt(window.localStorage.getItem("share_albums_offset"));
|
||||
|
||||
if(this.offset > 0 || !offset) {
|
||||
return this.batchSize;
|
||||
}
|
||||
|
||||
return offset + this.batchSize;
|
||||
},
|
||||
setOffset(offset) {
|
||||
this.offset = offset;
|
||||
window.localStorage.setItem("share_albums_offset", offset);
|
||||
},
|
||||
showUpload() {
|
||||
Event.publish("dialog.upload");
|
||||
},
|
||||
selectRange(rangeEnd, models) {
|
||||
if (!models || !models[rangeEnd] || !(models[rangeEnd] instanceof RestModel)) {
|
||||
console.warn("selectRange() - invalid arguments:", rangeEnd, models);
|
||||
return;
|
||||
}
|
||||
|
||||
let rangeStart = models.findIndex((m) => m.getId() === this.lastId);
|
||||
|
||||
if (rangeStart === -1) {
|
||||
this.toggleSelection(models[rangeEnd].getId());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rangeStart > rangeEnd) {
|
||||
const newEnd = rangeStart;
|
||||
rangeStart = rangeEnd;
|
||||
rangeEnd = newEnd;
|
||||
}
|
||||
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
this.addSelection(models[i].getId());
|
||||
}
|
||||
|
||||
return (rangeEnd - rangeStart) + 1;
|
||||
},
|
||||
onSelect(ev, index) {
|
||||
const inputType = this.input.eval(ev, index);
|
||||
|
||||
if (inputType !== ClickShort) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.shiftKey) {
|
||||
this.selectRange(index, this.results);
|
||||
} else {
|
||||
this.toggleSelection(this.results[index].getId());
|
||||
}
|
||||
},
|
||||
onClick(ev, index) {
|
||||
const inputType = this.input.eval(ev, index);
|
||||
const longClick = inputType === ClickLong;
|
||||
|
||||
if (inputType === InputInvalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (longClick || this.selection.length > 0) {
|
||||
if (longClick || ev.shiftKey) {
|
||||
this.selectRange(index, this.results);
|
||||
} else {
|
||||
this.toggleSelection(this.results[index].getId());
|
||||
}
|
||||
} else {
|
||||
this.$router.push(this.results[index].route(this.view));
|
||||
}
|
||||
},
|
||||
onContextMenu(ev, index) {
|
||||
if (this.$isMobile) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.results[index]) {
|
||||
this.selectRange(index, this.results);
|
||||
}
|
||||
}
|
||||
},
|
||||
clearQuery() {
|
||||
this.filter.q = '';
|
||||
this.search();
|
||||
},
|
||||
loadMore() {
|
||||
if (this.scrollDisabled) return;
|
||||
|
||||
this.scrollDisabled = true;
|
||||
this.listen = false;
|
||||
|
||||
const count = this.dirty ? (this.page + 2) * this.batchSize : this.batchSize;
|
||||
const offset = this.dirty ? 0 : this.offset;
|
||||
|
||||
const params = {
|
||||
count: count,
|
||||
offset: offset,
|
||||
};
|
||||
|
||||
Object.assign(params, this.lastFilter);
|
||||
|
||||
if (this.staticFilter) {
|
||||
Object.assign(params, this.staticFilter);
|
||||
}
|
||||
|
||||
Album.search(params).then(resp => {
|
||||
this.results = this.dirty ? resp.models : this.results.concat(resp.models);
|
||||
|
||||
this.scrollDisabled = (resp.count < resp.limit);
|
||||
|
||||
if (this.scrollDisabled) {
|
||||
this.setOffset(resp.offset);
|
||||
|
||||
if (this.results.length > 1) {
|
||||
this.$notify.info(this.$gettextInterpolate(this.$gettext("All %{n} albums loaded"), {n: this.results.length}));
|
||||
}
|
||||
} else {
|
||||
this.setOffset(resp.offset + resp.limit);
|
||||
this.page++;
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
|
||||
this.$emit("scrollRefresh");
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
this.scrollDisabled = false;
|
||||
}).finally(() => {
|
||||
this.dirty = false;
|
||||
this.loading = false;
|
||||
this.listen = true;
|
||||
});
|
||||
},
|
||||
updateQuery() {
|
||||
this.filter.q = this.filter.q.trim();
|
||||
|
||||
const query = {
|
||||
view: this.settings.view
|
||||
};
|
||||
|
||||
Object.assign(query, this.filter);
|
||||
|
||||
for (let key in query) {
|
||||
if (query[key] === undefined || !query[key]) {
|
||||
delete query[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.$route.query) === JSON.stringify(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.replace({query: query});
|
||||
},
|
||||
searchParams() {
|
||||
const params = {
|
||||
count: this.searchCount(),
|
||||
offset: this.offset,
|
||||
};
|
||||
|
||||
Object.assign(params, this.filter);
|
||||
|
||||
if (this.staticFilter) {
|
||||
Object.assign(params, this.staticFilter);
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
search() {
|
||||
/**
|
||||
* re-creating the last scroll-position should only ever happen when using
|
||||
* back-navigation. We therefore reset the remembered scroll-position
|
||||
* in any other scenario
|
||||
*/
|
||||
if (!window.backwardsNavigationDetected) {
|
||||
this.setOffset(0);
|
||||
}
|
||||
|
||||
this.scrollDisabled = true;
|
||||
|
||||
// Don't query the same data more than once
|
||||
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
|
||||
this.$nextTick(() => this.$emit("scrollRefresh"));
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(this.lastFilter, this.filter);
|
||||
|
||||
this.offset = 0;
|
||||
this.page = 0;
|
||||
this.loading = true;
|
||||
this.listen = false;
|
||||
|
||||
const params = this.searchParams();
|
||||
|
||||
Album.search(params).then(resp => {
|
||||
this.offset = resp.limit;
|
||||
this.results = resp.models;
|
||||
|
||||
this.scrollDisabled = (resp.count < resp.limit);
|
||||
|
||||
if (this.scrollDisabled) {
|
||||
if (!this.results.length) {
|
||||
this.$notify.warn(this.$gettext("No albums found"));
|
||||
} else if (this.results.length === 1) {
|
||||
this.$notify.info(this.$gettext("One album found"));
|
||||
} else {
|
||||
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} albums found"), {n: this.results.length}));
|
||||
}
|
||||
} else {
|
||||
this.$notify.info(this.$gettext('More than 20 albums found'));
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
|
||||
this.$emit("scrollRefresh");
|
||||
}
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
this.dirty = false;
|
||||
this.loading = false;
|
||||
this.listen = true;
|
||||
});
|
||||
},
|
||||
refresh() {
|
||||
if (this.loading) return;
|
||||
this.loading = true;
|
||||
this.page = 0;
|
||||
this.dirty = true;
|
||||
this.scrollDisabled = false;
|
||||
this.loadMore();
|
||||
},
|
||||
create() {
|
||||
let title = DateTime.local().toFormat("LLLL yyyy");
|
||||
|
||||
if (this.results.findIndex(a => a.Title.startsWith(title)) !== -1) {
|
||||
const existing = this.results.filter(a => a.Title.startsWith(title));
|
||||
title = `${title} (${existing.length + 1})`;
|
||||
}
|
||||
|
||||
const album = new Album({"Title": title, "Favorite": false});
|
||||
|
||||
album.save();
|
||||
},
|
||||
onSave(album) {
|
||||
album.update();
|
||||
},
|
||||
addSelection(uid) {
|
||||
const pos = this.selection.indexOf(uid);
|
||||
|
||||
if (pos === -1) {
|
||||
if (this.selection.length >= MaxItems) {
|
||||
Notify.warn(this.$gettext("Can't select more items"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.push(uid);
|
||||
this.lastId = uid;
|
||||
}
|
||||
},
|
||||
toggleSelection(uid) {
|
||||
const pos = this.selection.indexOf(uid);
|
||||
|
||||
if (pos !== -1) {
|
||||
this.selection.splice(pos, 1);
|
||||
this.lastId = "";
|
||||
} else {
|
||||
if (this.selection.length >= MaxItems) {
|
||||
Notify.warn(this.$gettext("Can't select more items"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.push(uid);
|
||||
this.lastId = uid;
|
||||
}
|
||||
},
|
||||
removeSelection(uid) {
|
||||
const pos = this.selection.indexOf(uid);
|
||||
|
||||
if (pos !== -1) {
|
||||
this.selection.splice(pos, 1);
|
||||
this.lastId = "";
|
||||
}
|
||||
},
|
||||
clearSelection() {
|
||||
this.selection.splice(0, this.selection.length);
|
||||
this.lastId = "";
|
||||
},
|
||||
onUpdate(ev, data) {
|
||||
if (!this.listen) return;
|
||||
|
||||
if (!data || !data.entities || !Array.isArray(data.entities)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = ev.split('.')[1];
|
||||
|
||||
switch (type) {
|
||||
case 'updated':
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const values = data.entities[i];
|
||||
const model = this.results.find((m) => m.UID === values.UID);
|
||||
|
||||
if (model) {
|
||||
for (let key in values) {
|
||||
if (values.hasOwnProperty(key) && values[key] != null && typeof values[key] !== "object") {
|
||||
model[key] = values[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let categories = [{"value": "", "text": this.$gettext("All Categories")}];
|
||||
|
||||
if (this.$config.albumCategories().length > 0) {
|
||||
categories = categories.concat(this.$config.albumCategories().map(cat => {
|
||||
return {"value": cat, "text": cat};
|
||||
}));
|
||||
}
|
||||
|
||||
this.categories = categories;
|
||||
|
||||
break;
|
||||
case 'deleted':
|
||||
this.dirty = true;
|
||||
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const uid = data.entities[i];
|
||||
const index = this.results.findIndex((m) => m.UID === uid);
|
||||
|
||||
if (index >= 0) {
|
||||
this.results.splice(index, 1);
|
||||
}
|
||||
|
||||
this.removeSelection(uid);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'created':
|
||||
this.dirty = true;
|
||||
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const values = data.entities[i];
|
||||
const index = this.results.findIndex((m) => m.UID === values.UID);
|
||||
|
||||
if (index === -1 && this.staticFilter.type === values.Type) {
|
||||
this.results.unshift(new Album(values));
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn("unexpected event type", ev);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
|
||||
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
|
||||
*/
|
||||
|
||||
import PNavigation from "navigation.vue";
|
||||
import PNotify from "component/notify.vue";
|
||||
import PScrollTop from "component/scroll-top.vue";
|
||||
import PLoadingBar from "component/loading-bar.vue";
|
||||
import PPhotoViewer from "component/photo-viewer.vue";
|
||||
import PVideoPlayer from "component/video/player.vue";
|
||||
import PPhotoCards from "component/photo/cards.vue";
|
||||
import PPhotoMosaic from "component/photo/mosaic.vue";
|
||||
import PPhotoList from "component/photo/list.vue";
|
||||
import PPhotoClipboard from "photo/clipboard.vue";
|
||||
import PAlbumClipboard from "album/clipboard.vue";
|
||||
|
||||
const components = {};
|
||||
|
||||
components.install = (Vue) => {
|
||||
Vue.component("PNavigation", PNavigation);
|
||||
Vue.component("PNotify", PNotify);
|
||||
Vue.component("PScrollTop", PScrollTop);
|
||||
Vue.component("PLoadingBar", PLoadingBar);
|
||||
Vue.component("PPhotoViewer", PPhotoViewer);
|
||||
Vue.component("PVideoPlayer", PVideoPlayer);
|
||||
Vue.component("PPhotoCards", PPhotoCards);
|
||||
Vue.component("PPhotoMosaic", PPhotoMosaic);
|
||||
Vue.component("PPhotoList", PPhotoList);
|
||||
Vue.component("PPhotoClipboard", PPhotoClipboard);
|
||||
Vue.component("PAlbumClipboard", PAlbumClipboard);
|
||||
};
|
||||
|
||||
export default components;
|
|
@ -1,78 +0,0 @@
|
|||
<template>
|
||||
<div id="p-navigation">
|
||||
<v-toolbar dark fixed flat scroll-off-screen :dense="$vuetify.breakpoint.smAndDown" color="navigation darken-1" class="nav-small">
|
||||
<v-toolbar-title>
|
||||
<button @click.stop.prevent="goHome">
|
||||
{{ page.title }}
|
||||
</button>
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-avatar
|
||||
tile
|
||||
:size="28"
|
||||
class="clickable"
|
||||
@click.stop.prevent="openSite"
|
||||
>
|
||||
<img :src="$config.staticUri + '/img/logo-white.svg'" alt="Logo">
|
||||
</v-avatar>
|
||||
</v-toolbar>
|
||||
<v-toolbar dark flat :dense="$vuetify.breakpoint.smAndDown" color="#fafafa">
|
||||
</v-toolbar>
|
||||
<div id="imprint"><a href="https://photoprism.app/" target="_blank">Shared with PhotoPrism</a></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "PNavigation",
|
||||
data() {
|
||||
return {
|
||||
drawer: null,
|
||||
mini: true,
|
||||
session: this.$session,
|
||||
public: this.$config.get("public"),
|
||||
readonly: this.$config.get("readonly"),
|
||||
config: this.$config.values,
|
||||
page: this.$config.page,
|
||||
upload: {
|
||||
subscription: null,
|
||||
dialog: false,
|
||||
},
|
||||
edit: {
|
||||
subscription: null,
|
||||
dialog: false,
|
||||
album: null,
|
||||
selection: [],
|
||||
index: 0,
|
||||
},
|
||||
token: this.$route.params.token,
|
||||
uid: this.$route.params.uid,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
auth() {
|
||||
return this.session.auth || this.public;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
goHome() {
|
||||
if (this.$route.name !== "albums") {
|
||||
this.$router.push({name: 'albums', params: {token: this.$route.params.token}});
|
||||
}
|
||||
},
|
||||
feature(name) {
|
||||
return this.$config.values.settings.features[name];
|
||||
},
|
||||
openSite() {
|
||||
window.open("https://photoprism.app/", "_blank");
|
||||
},
|
||||
showNavigation() {
|
||||
this.drawer = true;
|
||||
this.mini = false;
|
||||
},
|
||||
logout() {
|
||||
this.$session.logout();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,108 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-container v-if="selection.length > 0" fluid class="pa-0">
|
||||
<v-speed-dial
|
||||
id="t-clipboard" v-model="expanded" fixed
|
||||
bottom
|
||||
right
|
||||
direction="top"
|
||||
transition="slide-y-reverse-transition"
|
||||
class="p-clipboard p-photo-clipboard"
|
||||
>
|
||||
<template #activator>
|
||||
<v-btn
|
||||
fab
|
||||
dark
|
||||
color="accent darken-2"
|
||||
class="action-menu"
|
||||
>
|
||||
<v-icon v-if="selection.length === 0">menu</v-icon>
|
||||
<span v-else class="count-clipboard">{{ selection.length }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-btn
|
||||
v-if="context !== 'archive'" fab dark
|
||||
small
|
||||
:title="$gettext('Download')"
|
||||
color="download"
|
||||
:disabled="!$config.feature('download')"
|
||||
class="action-download"
|
||||
@click.stop="download()"
|
||||
>
|
||||
<v-icon>get_app</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
fab dark small
|
||||
color="accent"
|
||||
class="action-clear"
|
||||
@click.stop="clearClipboard()"
|
||||
>
|
||||
<v-icon>clear</v-icon>
|
||||
</v-btn>
|
||||
</v-speed-dial>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Api from "common/api";
|
||||
import Notify from "common/notify";
|
||||
import download from "common/download";
|
||||
import Photo from "model/photo";
|
||||
|
||||
export default {
|
||||
name: 'PPhotoClipboard',
|
||||
props: {
|
||||
selection: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
refresh: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
album: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
context: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: this.$config.values,
|
||||
expanded: false,
|
||||
dialog: {
|
||||
archive: false,
|
||||
album: false,
|
||||
share: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clearClipboard() {
|
||||
this.$clipboard.clear();
|
||||
this.expanded = false;
|
||||
},
|
||||
download() {
|
||||
switch (this.selection.length) {
|
||||
case 0: return;
|
||||
case 1: new Photo().find(this.selection[0]).then(p => p.downloadAll()); break;
|
||||
default: Api.post("zip", {"photos": this.selection}).then(r => {
|
||||
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
|
||||
});
|
||||
}
|
||||
|
||||
Notify.success(this.$gettext("Downloading…"));
|
||||
|
||||
this.expanded = false;
|
||||
},
|
||||
onDownload(path) {
|
||||
download(path, "photos.zip");
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -1,604 +0,0 @@
|
|||
<template>
|
||||
<div v-infinite-scroll="loadMore" class="p-page p-page-album-photos" style="user-select: none"
|
||||
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
|
||||
:infinite-scroll-listen-for-event="'scrollRefresh'">
|
||||
|
||||
<v-form ref="form" lazy-validation dense
|
||||
autocomplete="off" class="p-photo-toolbar p-album-toolbar"
|
||||
accept-charset="UTF-8" @submit.prevent="updateQuery()">
|
||||
<v-toolbar flat color="secondary" :dense="$vuetify.breakpoint.smAndDown">
|
||||
<v-toolbar-title>
|
||||
{{ model.Title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn icon class="hidden-xs-only action-reload" @click.stop="refresh()">
|
||||
<v-icon>refresh</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="$config.feature('download')" icon class="hidden-xs-only action-download" :title="$gettext('Download')"
|
||||
@click.stop="download()">
|
||||
<v-icon>get_app</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-btn v-if="settings.view === 'cards'" icon class="action-view-list" @click.stop="setView('list')">
|
||||
<v-icon>view_list</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else-if="settings.view === 'list'" icon class="action-view-mosaic" @click.stop="setView('mosaic')">
|
||||
<v-icon>view_comfy</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else icon class="action-view-cards" @click.stop="setView('cards')">
|
||||
<v-icon>view_column</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<template v-if="model.Description">
|
||||
<v-card flat class="px-2 py-1 hidden-sm-and-down"
|
||||
color="secondary-light"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ model.Description }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card flat class="pa-0 hidden-md-and-up"
|
||||
color="secondary-light"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ model.Description }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-form>
|
||||
|
||||
<v-container v-if="loading" fluid class="pa-4">
|
||||
<v-progress-linear color="secondary-dark" :indeterminate="true"></v-progress-linear>
|
||||
</v-container>
|
||||
<v-container v-else fluid class="pa-0">
|
||||
<p-scroll-top></p-scroll-top>
|
||||
|
||||
<p-photo-clipboard :refresh="refresh"
|
||||
:selection="selection"
|
||||
:album="model" context="album"></p-photo-clipboard>
|
||||
|
||||
<p-photo-mosaic v-if="settings.view === 'mosaic'"
|
||||
:photos="results"
|
||||
:select-mode="selectMode"
|
||||
:filter="filter"
|
||||
:album="model"
|
||||
:edit-photo="editPhoto"
|
||||
:open-photo="openPhoto"
|
||||
:is-shared-view="true"></p-photo-mosaic>
|
||||
<p-photo-list v-else-if="settings.view === 'list'"
|
||||
:photos="results"
|
||||
:select-mode="selectMode"
|
||||
:filter="filter"
|
||||
:album="model"
|
||||
:open-photo="openPhoto"
|
||||
:edit-photo="editPhoto"
|
||||
:open-location="openLocation"
|
||||
:is-shared-view="true"></p-photo-list>
|
||||
<p-photo-cards v-else
|
||||
:photos="results"
|
||||
:select-mode="selectMode"
|
||||
:filter="filter"
|
||||
:album="model"
|
||||
:open-photo="openPhoto"
|
||||
:edit-photo="editPhoto"
|
||||
:open-location="openLocation"
|
||||
:is-shared-view="true"></p-photo-cards>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {Photo, MediaLive, MediaRaw, MediaVideo, MediaAnimated} from "model/photo";
|
||||
import Album from "model/album";
|
||||
import Thumb from "model/thumb";
|
||||
import Event from "pubsub-js";
|
||||
import Notify from "common/notify";
|
||||
import download from "common/download";
|
||||
import Viewer from "common/viewer";
|
||||
|
||||
export default {
|
||||
name: 'PPageAlbumPhotos',
|
||||
props: {
|
||||
staticFilter: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const uid = this.$route.params.uid;
|
||||
const query = this.$route.query;
|
||||
const routeName = this.$route.name;
|
||||
const order = query['order'] ? query['order'] : 'oldest';
|
||||
const camera = query['camera'] ? parseInt(query['camera']) : 0;
|
||||
const q = query['q'] ? query['q'] : '';
|
||||
const country = query['country'] ? query['country'] : '';
|
||||
const view = this.viewType();
|
||||
const filter = {country: country, camera: camera, order: order, q: q};
|
||||
const settings = {view: view};
|
||||
const batchSize = Photo.batchSize();
|
||||
|
||||
return {
|
||||
hasPlaces: this.$config.allow("places", "view") && this.$config.feature("places"),
|
||||
canSearchPlaces: this.$config.allow("places", "search") && this.$config.feature("places"),
|
||||
subscriptions: [],
|
||||
listen: false,
|
||||
dirty: false,
|
||||
complete: false,
|
||||
model: new Album(),
|
||||
uid: uid,
|
||||
results: [],
|
||||
scrollDisabled: true,
|
||||
scrollDistance: window.innerHeight * 6,
|
||||
batchSize: batchSize,
|
||||
offset: 0,
|
||||
page: 0,
|
||||
selection: this.$clipboard.selection,
|
||||
settings: settings,
|
||||
filter: filter,
|
||||
lastFilter: {},
|
||||
routeName: routeName,
|
||||
loading: true,
|
||||
token: this.$route.params.token,
|
||||
viewer: {
|
||||
results: [],
|
||||
loading: false,
|
||||
complete: false,
|
||||
dirty: false,
|
||||
batchSize: batchSize > 160 ? 480 : batchSize * 3
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectMode: function() {
|
||||
return this.selection.length > 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route'() {
|
||||
const query = this.$route.query;
|
||||
|
||||
this.filter.q = query['q'] ? query['q'] : '';
|
||||
this.filter.camera = query['camera'] ? parseInt(query['camera']) : 0;
|
||||
this.filter.country = query['country'] ? query['country'] : '';
|
||||
this.settings.view = this.viewType();
|
||||
this.lastFilter = {};
|
||||
this.routeName = this.$route.name;
|
||||
|
||||
if (this.uid !== this.$route.params.uid) {
|
||||
this.uid = this.$route.params.uid;
|
||||
this.findAlbum().then(() => this.search());
|
||||
} else {
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const token = this.$route.params.token;
|
||||
|
||||
if (this.$session.hasToken(token)) {
|
||||
this.findAlbum().then(() => this.search());
|
||||
} else {
|
||||
this.$session.redeemToken(token).then(() => {
|
||||
this.findAlbum().then(() => this.search());
|
||||
});
|
||||
}
|
||||
|
||||
this.subscriptions.push(Event.subscribe("albums.updated", (ev, data) => this.onAlbumsUpdated(ev, data)));
|
||||
this.subscriptions.push(Event.subscribe("photos", (ev, data) => this.onUpdate(ev, data)));
|
||||
|
||||
this.subscriptions.push(Event.subscribe("touchmove.top", () => this.refresh()));
|
||||
this.subscriptions.push(Event.subscribe("touchmove.bottom", () => this.loadMore()));
|
||||
},
|
||||
destroyed() {
|
||||
for (let i = 0; i < this.subscriptions.length; i++) {
|
||||
Event.unsubscribe(this.subscriptions[i]);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setView(name) {
|
||||
this.settings.view = name;
|
||||
this.updateQuery();
|
||||
},
|
||||
viewType() {
|
||||
let queryParam = this.$route.query['view'] ? this.$route.query['view'] : "";
|
||||
let defaultType = window.localStorage.getItem("photos_view");
|
||||
let storedType = window.localStorage.getItem("share_photos_view");
|
||||
|
||||
if (queryParam) {
|
||||
window.localStorage.setItem("share_photos_view", queryParam);
|
||||
return queryParam;
|
||||
} else if (storedType) {
|
||||
return storedType;
|
||||
} else if (defaultType) {
|
||||
return defaultType;
|
||||
} else if (window.innerWidth < 960) {
|
||||
return 'mosaic';
|
||||
}
|
||||
|
||||
return 'cards';
|
||||
},
|
||||
openLocation(index) {
|
||||
if (!this.hasPlaces) {
|
||||
return;
|
||||
}
|
||||
|
||||
const photo = this.results[index];
|
||||
|
||||
if (photo && photo.CellID && photo.CellID !== "zz") {
|
||||
this.$router.push({name: "places_scope", params: {s: this.uid, q: photo.CellID}});
|
||||
} else {
|
||||
this.$router.push({name: "places_scope", params: {s: this.uid, q: ""}});
|
||||
}
|
||||
},
|
||||
editPhoto(index) {
|
||||
let selection = this.results.map((p) => {
|
||||
return p.getId();
|
||||
});
|
||||
|
||||
// Open Edit Dialog
|
||||
Event.publish("dialog.edit", {selection: selection, album: this.album, index: index});
|
||||
},
|
||||
openPhoto(index, showMerged = false, preferVideo = false) {
|
||||
if (this.loading || !this.listen || this.viewer.loading || !this.results[index]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selected = this.results[index];
|
||||
|
||||
// Don't open as stack when user is selecting pictures, or a RAW has only one JPEG.
|
||||
if (this.selection.length > 0 || selected.Type === MediaRaw && selected.jpegFiles().length < 2) {
|
||||
showMerged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the file is a video or an animation (like gif), then we always play
|
||||
* it in the video-player.
|
||||
* If the file is a live-image (an image with an embedded video), then we only
|
||||
* play it in the video-player if specifically requested.
|
||||
* This is because:
|
||||
* 1. the lower-resolution video in these files is already
|
||||
* played when hovering the element (which does not happen for regular
|
||||
* video files)
|
||||
* 2. The video in live-images is an addon. The main focus is usually still
|
||||
* the high resolution image inside
|
||||
*
|
||||
* preferVideo is true, when the user explicitly clicks the live-image-icon.
|
||||
*/
|
||||
if (preferVideo && selected.Type === MediaLive || selected.Type === MediaVideo || selected.Type === MediaAnimated) {
|
||||
if (selected.isPlayable()) {
|
||||
this.$viewer.play({video: selected, album: this.album});
|
||||
} else {
|
||||
this.$viewer.show(Thumb.fromPhotos(this.results), index);
|
||||
}
|
||||
} else if (showMerged) {
|
||||
this.$viewer.show(Thumb.fromFiles([selected]), 0);
|
||||
} else {
|
||||
Viewer.show(this, index);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
loadMore() {
|
||||
if (this.scrollDisabled || this.$scrollbar.disabled()) return;
|
||||
|
||||
this.scrollDisabled = true;
|
||||
this.listen = false;
|
||||
|
||||
if (this.dirty) {
|
||||
this.viewer.dirty = true;
|
||||
}
|
||||
|
||||
const count = this.dirty ? (this.page + 2) * this.batchSize : this.batchSize;
|
||||
const offset = this.dirty ? 0 : this.offset;
|
||||
|
||||
const params = {
|
||||
count: count,
|
||||
offset: offset,
|
||||
s: this.uid,
|
||||
filter: this.model.Filter ? this.model.Filter : "",
|
||||
merged: true,
|
||||
};
|
||||
|
||||
Object.assign(params, this.lastFilter);
|
||||
|
||||
if (this.staticFilter) {
|
||||
Object.assign(params, this.staticFilter);
|
||||
}
|
||||
|
||||
Photo.search(params).then(response => {
|
||||
this.results = Photo.mergeResponse(this.results, response);
|
||||
this.complete = (response.count < count);
|
||||
this.scrollDisabled = this.complete;
|
||||
|
||||
if (this.complete) {
|
||||
this.offset = offset;
|
||||
|
||||
if (this.results.length > 1) {
|
||||
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} pictures found"), {n: this.results.length}));
|
||||
}
|
||||
} else if (this.results.length >= Photo.limit()) {
|
||||
this.offset = offset;
|
||||
this.scrollDisabled = true;
|
||||
this.complete = true;
|
||||
this.$notify.warn(this.$gettext("Can't load more, limit reached"));
|
||||
} else {
|
||||
this.offset = offset + count;
|
||||
this.page++;
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
|
||||
this.$emit("scrollRefresh");
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
this.scrollDisabled = false;
|
||||
}).finally(() => {
|
||||
this.dirty = false;
|
||||
this.loading = false;
|
||||
this.listen = true;
|
||||
});
|
||||
},
|
||||
updateSettings(props) {
|
||||
if (!props || typeof props !== "object" || props.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (!this.settings.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
this.settings[key] = value.trim();
|
||||
break;
|
||||
default:
|
||||
this.settings[key] = value;
|
||||
}
|
||||
|
||||
window.localStorage.setItem("share_photos_"+key, this.settings[key]);
|
||||
}
|
||||
},
|
||||
updateFilter(props) {
|
||||
if (!props || typeof props !== "object" || props.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (!this.filter.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
this.filter[key] = value.trim();
|
||||
break;
|
||||
default:
|
||||
this.filter[key] = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateQuery(props) {
|
||||
this.updateFilter(props);
|
||||
|
||||
const query = {
|
||||
view: this.settings.view
|
||||
};
|
||||
|
||||
Object.assign(query, this.filter);
|
||||
|
||||
for (let key in query) {
|
||||
if (query[key] === undefined || !query[key]) {
|
||||
delete query[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON.stringify(this.$route.query) === JSON.stringify(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.replace({query: query});
|
||||
},
|
||||
searchParams() {
|
||||
const params = {
|
||||
count: this.batchSize,
|
||||
offset: this.offset,
|
||||
s: this.uid,
|
||||
filter: this.model.Filter ? this.model.Filter : "",
|
||||
merged: true,
|
||||
};
|
||||
|
||||
Object.assign(params, this.filter);
|
||||
|
||||
if (this.staticFilter) {
|
||||
Object.assign(params, this.staticFilter);
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
refresh(props) {
|
||||
this.updateSettings(props);
|
||||
|
||||
if (this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
this.page = 0;
|
||||
this.dirty = true;
|
||||
this.complete = false;
|
||||
this.scrollDisabled = false;
|
||||
|
||||
this.loadMore();
|
||||
},
|
||||
search() {
|
||||
this.scrollDisabled = true;
|
||||
|
||||
// Don't query the same data more than once
|
||||
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
|
||||
this.$nextTick(() => this.$emit("scrollRefresh"));
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(this.lastFilter, this.filter);
|
||||
|
||||
this.offset = 0;
|
||||
this.page = 0;
|
||||
this.loading = true;
|
||||
this.listen = false;
|
||||
this.complete = false;
|
||||
|
||||
const params = this.searchParams();
|
||||
|
||||
Photo.search(params).then(response => {
|
||||
this.offset = this.batchSize;
|
||||
this.results = response.models;
|
||||
this.viewer.results = [];
|
||||
this.viewer.complete = false;
|
||||
this.complete = (response.count < this.batchSize);
|
||||
this.scrollDisabled = this.complete;
|
||||
|
||||
if (this.complete) {
|
||||
if (!this.results.length) {
|
||||
this.$notify.warn(this.$gettext("No pictures found"));
|
||||
} else if (this.results.length === 1) {
|
||||
this.$notify.info(this.$gettext("One picture found"));
|
||||
} else {
|
||||
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} pictures found"), {n: this.results.length}));
|
||||
}
|
||||
} else {
|
||||
this.$notify.info(this.$gettextInterpolate(this.$gettext("More than %{n} pictures found"), {n: 50}));
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$root.$el.clientHeight <= window.document.documentElement.clientHeight + 300) {
|
||||
this.$emit("scrollRefresh");
|
||||
}
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
this.dirty = false;
|
||||
this.loading = false;
|
||||
this.listen = true;
|
||||
});
|
||||
},
|
||||
findAlbum() {
|
||||
return this.model.find(this.uid).then(m => {
|
||||
this.model = m;
|
||||
|
||||
this.filter.order = m.Order;
|
||||
window.document.title = this.model.Title;
|
||||
|
||||
return Promise.resolve(this.model);
|
||||
});
|
||||
},
|
||||
onAlbumsUpdated(ev, data) {
|
||||
if (!this.listen) return;
|
||||
|
||||
if (!data || !data.entities || !Array.isArray(data.entities)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
if (this.model.UID === data.entities[i].UID) {
|
||||
let values = data.entities[i];
|
||||
|
||||
for (let key in values) {
|
||||
if (values.hasOwnProperty(key)) {
|
||||
this.model[key] = values[key];
|
||||
}
|
||||
}
|
||||
|
||||
window.document.title = `${this.$config.get("siteTitle")}: ${this.model.Title}`;
|
||||
|
||||
this.dirty = true;
|
||||
this.complete = false;
|
||||
this.scrollDisabled = false;
|
||||
|
||||
if (this.filter.order !== this.model.Order) {
|
||||
this.filter.order = this.model.Order;
|
||||
this.updateQuery();
|
||||
} else {
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
updateResults(entity) {
|
||||
this.results.filter((m) => m.UID === entity.UID).forEach((m) => {
|
||||
for (let key in entity) {
|
||||
if (key !== "UID" && entity.hasOwnProperty(key) && entity[key] != null && typeof entity[key] !== "object") {
|
||||
m[key] = entity[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.viewer.results.filter((m) => m.UID === entity.UID).forEach((m) => {
|
||||
for (let key in entity) {
|
||||
if (key !== "UID" && entity.hasOwnProperty(key) && entity[key] != null && typeof entity[key] !== "object") {
|
||||
m[key] = entity[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
removeResult(results, uid) {
|
||||
const index = results.findIndex((m) => m.UID === uid);
|
||||
|
||||
if (index >= 0) {
|
||||
results.splice(index, 1);
|
||||
}
|
||||
},
|
||||
onUpdate(ev, data) {
|
||||
if (!this.listen) return;
|
||||
|
||||
if (!data || !data.entities) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = ev.split('.')[1];
|
||||
|
||||
switch (type) {
|
||||
case 'updated':
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
this.updateResults(data.entities[i]);
|
||||
}
|
||||
break;
|
||||
case 'restored':
|
||||
this.dirty = true;
|
||||
this.scrollDisabled = false;
|
||||
this.complete = false;
|
||||
|
||||
this.loadMore();
|
||||
|
||||
break;
|
||||
case 'deleted':
|
||||
case 'archived':
|
||||
this.dirty = true;
|
||||
this.complete = false;
|
||||
|
||||
for (let i = 0; i < data.entities.length; i++) {
|
||||
const uid = data.entities[i];
|
||||
|
||||
this.removeResult(this.results, uid);
|
||||
this.removeResult(this.viewer.results, uid);
|
||||
this.$clipboard.removeId(uid);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO: Needed?
|
||||
this.$forceUpdate();
|
||||
},
|
||||
download() {
|
||||
this.onDownload(`${this.$config.apiUri}/albums/${this.uid}/dl?t=${this.$config.downloadToken()}`);
|
||||
},
|
||||
onDownload(path) {
|
||||
Notify.success(this.$gettext("Downloading…"));
|
||||
|
||||
download(path, "album.zip");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -1,456 +0,0 @@
|
|||
<template>
|
||||
<v-container fluid fill-height :class="$config.aclClasses('places')" class="pa-0 p-page p-page-places">
|
||||
<div id="map" style="width: 100%; height: 100%;">
|
||||
<div class="map-control">
|
||||
<div class="maplibregl-ctrl maplibregl-ctrl-group">
|
||||
<v-text-field v-model.lazy.trim="filter.q"
|
||||
solo hide-details clearable flat single-line validate-on-blur
|
||||
class="input-search pa-0 ma-0"
|
||||
:label="$gettext('Search')"
|
||||
prepend-inner-icon="search"
|
||||
browser-autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="none"
|
||||
color="secondary-dark"
|
||||
@click:clear="clearQuery"
|
||||
@keyup.enter.native="formChange"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import maplibregl from "maplibre-gl";
|
||||
import Api from "common/api";
|
||||
import Thumb from "model/thumb";
|
||||
|
||||
export default {
|
||||
name: 'PPagePlaces',
|
||||
props: {
|
||||
staticFilter: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
initialized: false,
|
||||
map: null,
|
||||
markers: {},
|
||||
markersOnScreen: {},
|
||||
loading: false,
|
||||
url: "",
|
||||
attribution: '<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>',
|
||||
maxCount: 500000,
|
||||
options: {},
|
||||
mapFont: ["Open Sans Regular"],
|
||||
result: {},
|
||||
filter: {q: this.query()},
|
||||
lastFilter: {},
|
||||
config: this.$config.values,
|
||||
settings: this.$config.values.settings.maps,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'$route'() {
|
||||
this.filter.q = this.query();
|
||||
this.lastFilter = {};
|
||||
|
||||
this.search();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$scrollbar.hide();
|
||||
this.configureMap().then(() => this.renderMap());
|
||||
},
|
||||
destroyed() {
|
||||
this.$scrollbar.show();
|
||||
},
|
||||
methods: {
|
||||
configureMap() {
|
||||
return this.$config.load().finally(() => {
|
||||
const s = this.$config.values.settings.maps;
|
||||
const filter = {
|
||||
q: this.query(),
|
||||
};
|
||||
|
||||
let mapKey = "";
|
||||
|
||||
if (this.$config.has("mapKey")) {
|
||||
// Remove non-alphanumeric characters from key.
|
||||
mapKey = this.$config.get("mapKey").replace(/[^a-z0-9]/gi, '');
|
||||
}
|
||||
|
||||
const settings = this.$config.settings();
|
||||
|
||||
if (settings && settings.features.private) {
|
||||
filter.public = "true";
|
||||
}
|
||||
|
||||
if (settings && settings.features.review && (!this.staticFilter || !("quality" in this.staticFilter))) {
|
||||
filter.quality = "3";
|
||||
}
|
||||
|
||||
let mapOptions = {
|
||||
container: "map",
|
||||
style: "https://api.maptiler.com/maps/" + s.style + "/style.json?key=" + mapKey,
|
||||
glyphs: "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key=" + mapKey,
|
||||
attributionControl: true,
|
||||
customAttribution: this.attribution,
|
||||
zoom: 0,
|
||||
};
|
||||
|
||||
if (!mapKey || s.style === "offline") {
|
||||
mapOptions = {
|
||||
container: "map",
|
||||
style: {
|
||||
"version": 8,
|
||||
"sources": {
|
||||
"world": {
|
||||
"type": "geojson",
|
||||
"data": `${this.$config.staticUri}/geo/world.json`,
|
||||
"maxzoom": 6
|
||||
}
|
||||
},
|
||||
"glyphs": `${this.$config.staticUri}/font/{fontstack}/{range}.pbf`,
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": {
|
||||
"background-color": "#aadafe"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "land",
|
||||
type: "fill",
|
||||
source: "world",
|
||||
// "source-layer": "land",
|
||||
paint: {
|
||||
"fill-color": "#cbe5ca",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "country-abbrev",
|
||||
"type": "symbol",
|
||||
"source": "world",
|
||||
"maxzoom": 3,
|
||||
"layout": {
|
||||
"text-field": "{abbrev}",
|
||||
"text-font": ["Open Sans Semibold"],
|
||||
"text-transform": "uppercase",
|
||||
"text-max-width": 20,
|
||||
"text-size": {
|
||||
"stops": [[3, 10], [4, 11], [5, 12], [6, 16]]
|
||||
},
|
||||
"text-letter-spacing": {
|
||||
"stops": [[4, 0], [5, 1], [6, 2]]
|
||||
},
|
||||
"text-line-height": {
|
||||
"stops": [[5, 1.2], [6, 2]]
|
||||
}
|
||||
},
|
||||
"paint": {
|
||||
"text-halo-color": "#fff",
|
||||
"text-halo-width": 1
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "country-border",
|
||||
"type": "line",
|
||||
"source": "world",
|
||||
"paint": {
|
||||
"line-color": "#226688",
|
||||
"line-opacity": 0.25,
|
||||
"line-dasharray": [6, 2, 2, 2],
|
||||
"line-width": 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "country-name",
|
||||
"type": "symbol",
|
||||
"minzoom": 3,
|
||||
"source": "world",
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Open Sans Semibold"],
|
||||
"text-max-width": 20,
|
||||
"text-size": {
|
||||
"stops": [[3, 10], [4, 11], [5, 12], [6, 16]]
|
||||
}
|
||||
},
|
||||
"paint": {
|
||||
"text-halo-color": "#fff",
|
||||
"text-halo-width": 1
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
attributionControl: true,
|
||||
customAttribution: this.attribution,
|
||||
zoom: 0,
|
||||
};
|
||||
this.url = '';
|
||||
} else {
|
||||
this.url = 'https://api.maptiler.com/maps/' + s.style + '/{z}/{x}/{y}.png?key=' + mapKey;
|
||||
}
|
||||
|
||||
this.filter = filter;
|
||||
this.options = mapOptions;
|
||||
});
|
||||
},
|
||||
query: function () {
|
||||
return this.$route.params.q ? this.$route.params.q : '';
|
||||
},
|
||||
openPhoto(uid) {
|
||||
// Abort if uid is empty or results aren't loaded.
|
||||
if (!uid || this.loading || !this.result || !this.result.features || this.result.features.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get request parameters.
|
||||
const options = {
|
||||
params: {
|
||||
near: uid,
|
||||
count: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
this.loading = true;
|
||||
|
||||
// Perform get request to find nearby photos.
|
||||
return Api.get("geo/view", options).then((r) => {
|
||||
if (r && r.data && r.data.length > 0) {
|
||||
// Show photos.
|
||||
this.$viewer.show(Thumb.wrap(r.data), 0);
|
||||
} else {
|
||||
// Don't open viewer if nothing was found.
|
||||
this.$notify.warn(this.$gettext("No pictures found"));
|
||||
}
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
formChange() {
|
||||
if (this.loading) return;
|
||||
this.search();
|
||||
},
|
||||
clearQuery() {
|
||||
this.filter.q = '';
|
||||
this.search();
|
||||
},
|
||||
updateQuery() {
|
||||
if (this.loading) return;
|
||||
|
||||
if (this.query() !== this.filter.q) {
|
||||
if (this.filter.q) {
|
||||
this.$router.replace({name: "places_query", params: {q: this.filter.q}});
|
||||
} else {
|
||||
this.$router.replace({name: "places"});
|
||||
}
|
||||
}
|
||||
},
|
||||
searchParams() {
|
||||
const params = {
|
||||
count: this.maxCount,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
Object.assign(params, this.filter);
|
||||
|
||||
if (this.staticFilter) {
|
||||
Object.assign(params, this.staticFilter);
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
search() {
|
||||
if (this.loading) return;
|
||||
|
||||
// Don't query the same data more than once
|
||||
if (JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) return;
|
||||
this.loading = true;
|
||||
|
||||
Object.assign(this.lastFilter, this.filter);
|
||||
|
||||
this.updateQuery();
|
||||
|
||||
// Compose query params.
|
||||
const options = {
|
||||
params: this.searchParams(),
|
||||
};
|
||||
|
||||
// Fetch results from server.
|
||||
return Api.get("geo", options).then((response) => {
|
||||
if (!response.data.features || response.data.features.length === 0) {
|
||||
this.loading = false;
|
||||
|
||||
this.$notify.warn(this.$gettext("No pictures found"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.result = response.data;
|
||||
|
||||
this.map.getSource("photos").setData(this.result);
|
||||
|
||||
if (this.filter.q || !this.initialized) {
|
||||
this.map.fitBounds(this.result.bbox, {
|
||||
maxZoom: 17,
|
||||
padding: 100,
|
||||
duration: this.settings.animate,
|
||||
essential: false,
|
||||
animate: this.settings.animate > 0
|
||||
});
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
this.updateMarkers();
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
renderMap() {
|
||||
this.map = new maplibregl.Map(this.options);
|
||||
this.map.setLanguage(this.$config.values.settings.ui.language.split("-")[0]);
|
||||
|
||||
const controlPos = this.$rtl ? 'top-left' : 'top-right';
|
||||
|
||||
this.map.addControl(new maplibregl.NavigationControl({showCompass: true}), controlPos);
|
||||
this.map.addControl(new maplibregl.FullscreenControl({container: document.querySelector('body')}), controlPos);
|
||||
this.map.addControl(new maplibregl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
trackUserLocation: true
|
||||
}), controlPos);
|
||||
|
||||
this.map.on("load", () => this.onMapLoad());
|
||||
},
|
||||
updateMarkers() {
|
||||
if (this.loading) return;
|
||||
let newMarkers = {};
|
||||
let features = this.map.querySourceFeatures("photos");
|
||||
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
let coords = features[i].geometry.coordinates;
|
||||
let props = features[i].properties;
|
||||
if (props.cluster) continue;
|
||||
let id = features[i].id;
|
||||
|
||||
let marker = this.markers[id];
|
||||
let token = this.$config.previewToken();
|
||||
if (!marker) {
|
||||
let el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
el.title = props.Title;
|
||||
el.style.backgroundImage = `url(${this.$config.contentUri}/t/${props.Hash}/${token}/tile_50)`;
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
|
||||
el.addEventListener('click', () => this.openPhoto(props.UID));
|
||||
marker = this.markers[id] = new maplibregl.Marker({
|
||||
element: el
|
||||
}).setLngLat(coords);
|
||||
} else {
|
||||
marker.setLngLat(coords);
|
||||
}
|
||||
|
||||
newMarkers[id] = marker;
|
||||
|
||||
if (!this.markersOnScreen[id]) {
|
||||
marker.addTo(this.map);
|
||||
}
|
||||
}
|
||||
for (let id in this.markersOnScreen) {
|
||||
if (!newMarkers[id]) {
|
||||
this.markersOnScreen[id].remove();
|
||||
}
|
||||
}
|
||||
this.markersOnScreen = newMarkers;
|
||||
},
|
||||
onMapLoad() {
|
||||
this.map.addSource('photos', {
|
||||
type: 'geojson',
|
||||
data: null,
|
||||
cluster: true,
|
||||
clusterMaxZoom: 14, // Max zoom to cluster points on
|
||||
clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
|
||||
});
|
||||
|
||||
this.map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#2DC4B2',
|
||||
100,
|
||||
'#3BB3C3',
|
||||
750,
|
||||
'#669EC4'
|
||||
],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20,
|
||||
100,
|
||||
30,
|
||||
750,
|
||||
40
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
this.map.addLayer({
|
||||
id: 'cluster-count',
|
||||
type: 'symbol',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': this.mapFont,
|
||||
'text-size': 13
|
||||
}
|
||||
});
|
||||
|
||||
this.map.on('render', this.updateMarkers);
|
||||
|
||||
this.map.on('click', 'clusters', (e) => {
|
||||
const features = this.map.queryRenderedFeatures(e.point, {
|
||||
layers: ['clusters']
|
||||
});
|
||||
const clusterId = features[0].properties.cluster_id;
|
||||
this.map.getSource('photos').getClusterExpansionZoom(
|
||||
clusterId,
|
||||
(err, zoom) => {
|
||||
if (err) return;
|
||||
|
||||
this.map.easeTo({
|
||||
center: features[0].geometry.coordinates,
|
||||
zoom: zoom
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
this.map.on('mouseenter', 'clusters', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
this.map.on('mouseleave', 'clusters', () => {
|
||||
this.map.getCanvas().style.cursor = '';
|
||||
});
|
||||
|
||||
this.search();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import Albums from "share/albums.vue";
|
||||
import AlbumPhotos from "share/photos.vue";
|
||||
import Places from "pages/places.vue";
|
||||
|
||||
const c = window.__CONFIG__;
|
||||
const siteTitle = c.siteAuthor ? c.siteAuthor : c.name;
|
||||
const shareTitle = c.settings.share.title ? c.settings.share.title : siteTitle;
|
||||
|
||||
export default [
|
||||
{
|
||||
name: "home",
|
||||
path: "/",
|
||||
redirect: { name: "albums" },
|
||||
},
|
||||
{
|
||||
name: "albums",
|
||||
path: "/s/:token",
|
||||
component: Albums,
|
||||
meta: { title: shareTitle, auth: true, hideNav: true },
|
||||
props: { view: "album", staticFilter: { type: "" } },
|
||||
},
|
||||
{
|
||||
name: "album",
|
||||
path: "/s/:token/:uid",
|
||||
component: AlbumPhotos,
|
||||
meta: { title: shareTitle, auth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
name: "places_scope",
|
||||
path: "/places/:s/:q",
|
||||
component: Places,
|
||||
meta: { title: shareTitle, auth: true, hideNav: true },
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
redirect: { name: "albums" },
|
||||
},
|
||||
];
|
|
@ -190,8 +190,8 @@ Mock.onPost("api/v1/albums/66/links").reply(
|
|||
Password: "passwd",
|
||||
Expires: 8000,
|
||||
Slug: "christmas-2019",
|
||||
CanEdit: false,
|
||||
CanComment: false,
|
||||
Comment: "",
|
||||
Perm: 0,
|
||||
},
|
||||
mockHeaders
|
||||
);
|
||||
|
@ -199,8 +199,8 @@ Mock.onDelete("api/v1/albums/66/links/5").reply(200, { Success: "ok" }, mockHead
|
|||
Mock.onGet("api/v1/albums/66/links").reply(
|
||||
200,
|
||||
[
|
||||
{ UID: "sqcwh80ifesw74ht", Share: "aqcwh7weohhk49q2", Slug: "july-2020" },
|
||||
{ UID: "sqcwhxh1h58rf3c2", Share: "aqcwh7weohhk49q2" },
|
||||
{ UID: "sqcwh80ifesw74ht", ShareUID: "aqcwh7weohhk49q2", Slug: "july-2020" },
|
||||
{ UID: "sqcwhxh1h58rf3c2", ShareUID: "aqcwh7weohhk49q2" },
|
||||
],
|
||||
mockHeaders
|
||||
);
|
||||
|
|
|
@ -10,23 +10,24 @@ describe("model/link", () => {
|
|||
const link = new Link(values);
|
||||
const result = link.getDefaults();
|
||||
assert.equal(result.UID, 0);
|
||||
assert.equal(result.CanEdit, false);
|
||||
assert.equal(result.Share, "");
|
||||
assert.equal(result.Perm, 0);
|
||||
assert.equal(result.Comment, "");
|
||||
assert.equal(result.ShareUID, "");
|
||||
});
|
||||
|
||||
it("should get link url", () => {
|
||||
const values = { UID: 5, Token: "1234hhtbbt", Slug: "friends", Share: "family" };
|
||||
const values = { UID: 5, Token: "1234hhtbbt", Slug: "friends", ShareUID: "family" };
|
||||
const link = new Link(values);
|
||||
const result = link.url();
|
||||
assert.equal(result, "http://localhost:2342/s/1234hhtbbt/friends");
|
||||
const values2 = { UID: 5, Token: "", Share: "family" };
|
||||
const values2 = { UID: 5, Token: "", ShareUID: "family" };
|
||||
const link2 = new Link(values2);
|
||||
const result2 = link2.url();
|
||||
assert.equal(result2, "http://localhost:2342/s/…/family");
|
||||
});
|
||||
|
||||
it("should get link caption", () => {
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", Share: "family" };
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", ShareUID: "family" };
|
||||
const link = new Link(values);
|
||||
const result = link.caption();
|
||||
assert.equal(result, "/s/acfgbtth");
|
||||
|
@ -47,25 +48,25 @@ describe("model/link", () => {
|
|||
});
|
||||
|
||||
it("should get link slug", () => {
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", Share: "family" };
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", ShareUID: "family" };
|
||||
const link = new Link(values);
|
||||
const result = link.getSlug();
|
||||
assert.equal(result, "friends");
|
||||
});
|
||||
|
||||
it("should test has slug", () => {
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", Share: "family" };
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", ShareUID: "family" };
|
||||
const link = new Link(values);
|
||||
const result = link.hasSlug();
|
||||
assert.equal(result, true);
|
||||
const values2 = { UID: 5, Token: "AcfgbTTh", Share: "family" };
|
||||
const values2 = { UID: 5, Token: "AcfgbTTh", ShareUID: "family" };
|
||||
const link2 = new Link(values2);
|
||||
const result2 = link2.hasSlug();
|
||||
assert.equal(result2, false);
|
||||
});
|
||||
|
||||
it("should clone link", () => {
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", Share: "family" };
|
||||
const values = { UID: 5, Token: "AcfgbTTh", Slug: "friends", ShareUID: "family" };
|
||||
const link = new Link(values);
|
||||
const result = link.clone();
|
||||
assert.equal(result.Slug, "friends");
|
||||
|
@ -77,7 +78,7 @@ describe("model/link", () => {
|
|||
UID: 5,
|
||||
Token: "AcfgbTTh",
|
||||
Slug: "friends",
|
||||
Share: "family",
|
||||
ShareUID: "family",
|
||||
Expires: 80000,
|
||||
ModifiedAt: "2012-07-08T14:45:39Z",
|
||||
};
|
||||
|
|
2
go.mod
2
go.mod
|
@ -76,7 +76,7 @@ require (
|
|||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/goccy/go-json v0.9.11 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/zitadel/oidc v1.8.0
|
||||
github.com/zitadel/oidc v1.9.0
|
||||
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect
|
||||
golang.org/x/sys v0.0.0-20220913175220-63ea55921009 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
|
|
4
go.sum
4
go.sum
|
@ -450,8 +450,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
|||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/zitadel/logging v0.3.4/go.mod h1:aPpLQhE+v6ocNK0TWrBrd363hZ95KcI17Q1ixAQwZF0=
|
||||
github.com/zitadel/oidc v1.8.0 h1:FEUuAaZVgZv0dWGpCNcG1ov7COVDA5x2yzlYwqy8iTs=
|
||||
github.com/zitadel/oidc v1.8.0/go.mod h1:lbT3Wd/8MujrbLWdVm6Ll6VJjmAUfzW9SscvB4GwLTQ=
|
||||
github.com/zitadel/oidc v1.9.0 h1:U6d8S6+GOg5F8yGxkT7cvFGJD2DV3nqKESz9cS64k30=
|
||||
github.com/zitadel/oidc v1.9.0/go.mod h1:lbT3Wd/8MujrbLWdVm6Ll6VJjmAUfzW9SscvB4GwLTQ=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
|
|
|
@ -5,6 +5,10 @@ var Events = ACL{
|
|||
ResourceDefault: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ChannelUser: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantSubscribeOwn,
|
||||
},
|
||||
ChannelSession: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: GrantSubscribeOwn,
|
||||
|
|
|
@ -15,11 +15,11 @@ var Resources = ACL{
|
|||
},
|
||||
ResourceAlbums: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
RoleVisitor: GrantSearchShared,
|
||||
},
|
||||
ResourceFolders: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
RoleVisitor: GrantSearchShared,
|
||||
},
|
||||
ResourcePlaces: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
|
@ -27,11 +27,11 @@ var Resources = ACL{
|
|||
},
|
||||
ResourceCalendar: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
RoleVisitor: GrantSearchShared,
|
||||
},
|
||||
ResourceMoments: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
|
||||
RoleVisitor: GrantSearchShared,
|
||||
},
|
||||
ResourcePeople: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
|
@ -46,7 +46,8 @@ var Resources = ACL{
|
|||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ResourceSettings: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RoleVisitor: Grant{AccessOwn: true, ActionView: true},
|
||||
},
|
||||
ResourceFeedback: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package acl
|
||||
|
||||
const (
|
||||
ChannelUser Resource = "user"
|
||||
ChannelSession Resource = "session"
|
||||
ChannelAudit Resource = "audit"
|
||||
ChannelLog Resource = "log"
|
||||
|
|
|
@ -3,6 +3,7 @@ package acl
|
|||
// Predefined grants to simplify configuration.
|
||||
var (
|
||||
GrantFullAccess = Grant{FullAccess: true, AccessAll: true, AccessLibrary: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionDownload: true, ActionShare: true, ActionRate: true, ActionReact: true, ActionManage: true, ActionSubscribe: true}
|
||||
GrantSearchShared = Grant{AccessShared: true, ActionSearch: true, ActionView: true, ActionDownload: true}
|
||||
GrantSubscribeAll = Grant{AccessAll: true, ActionSubscribe: true}
|
||||
GrantSubscribeOwn = Grant{AccessOwn: true, ActionSubscribe: true}
|
||||
)
|
||||
|
|
|
@ -115,7 +115,7 @@ func CreateAlbum(router *gin.RouterGroup) {
|
|||
|
||||
// Publish event and create/update YAML backup.
|
||||
UpdateClientConfig()
|
||||
PublishAlbumEvent(EntityCreated, a.AlbumUID, c)
|
||||
// PublishAlbumEvent(EntityCreated, a.AlbumUID, c)
|
||||
SaveAlbumAsYaml(*a)
|
||||
|
||||
// Return as JSON.
|
||||
|
@ -169,7 +169,7 @@ func UpdateAlbum(router *gin.RouterGroup) {
|
|||
|
||||
event.SuccessMsg(i18n.MsgAlbumSaved)
|
||||
|
||||
PublishAlbumEvent(EntityUpdated, uid, c)
|
||||
// PublishAlbumEvent(EntityUpdated, uid, c)
|
||||
|
||||
SaveAlbumAsYaml(a)
|
||||
|
||||
|
@ -215,7 +215,7 @@ func DeleteAlbum(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
PublishAlbumEvent(EntityDeleted, id, c)
|
||||
// PublishAlbumEvent(EntityDeleted, id, c)
|
||||
|
||||
UpdateClientConfig()
|
||||
|
||||
|
|
|
@ -4,15 +4,16 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const UnknownIP = "0.0.0.0"
|
||||
|
||||
// ClientIP returns the client IP address from the request context or a placeholder if it is unknown.
|
||||
func ClientIP(c *gin.Context) (ip string) {
|
||||
if c == nil {
|
||||
// Should never happen.
|
||||
return "0.0.0.0"
|
||||
return UnknownIP
|
||||
} else if ip = c.ClientIP(); ip == "" {
|
||||
// Unit tests generally do not set a client IP. According to RFC 5737, the 192.0.2.0/24 subnet
|
||||
// is intended for use in documentation and examples.
|
||||
return "192.0.2.42"
|
||||
// Unit tests often do not set a client IP.
|
||||
return UnknownIP
|
||||
}
|
||||
|
||||
return ip
|
||||
|
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
@ -150,7 +151,8 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
|||
|
||||
// Subscribe to events.
|
||||
e := event.Subscribe(
|
||||
"session.*",
|
||||
"user.*.*.*",
|
||||
"session.*.*.*",
|
||||
"log.fatal",
|
||||
"log.error",
|
||||
"log.warning",
|
||||
|
@ -212,19 +214,26 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
|||
|
||||
wsAuth.mutex.RUnlock()
|
||||
|
||||
// Split topic into channel and event name.
|
||||
ch, ev := event.Topic(msg.Topic())
|
||||
// Split topic into sub-channels.
|
||||
ev := msg.Topic()
|
||||
ch := strings.Split(ev, ".")
|
||||
|
||||
// Message intended for a specific session only?
|
||||
if acl.ChannelSession.Equal(ch) {
|
||||
if s, topic := event.Topic(ev); s == sid && topic != "" {
|
||||
// Send to client with the matching session ID.
|
||||
wsSendMessage(topic, msg.Fields, ws, writeMutex)
|
||||
// Send the message only to authorized recipients.
|
||||
switch len(ch) {
|
||||
case 2:
|
||||
// Send to everyone who is allowed to subscribe.
|
||||
if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) {
|
||||
wsSendMessage(ev, msg.Fields, ws, writeMutex)
|
||||
}
|
||||
case 4:
|
||||
ev = strings.Join(ch[2:4], ".")
|
||||
if acl.ChannelUser.Equal(ch[0]) && ch[1] == user.UID() || acl.Events.AllowAll(acl.Resource(ch[2]), user.AclRole(), wsSubscribePerms) {
|
||||
// Send to matching user uid.
|
||||
wsSendMessage(ev, msg.Fields, ws, writeMutex)
|
||||
} else if acl.ChannelSession.Equal(ch[0]) && ch[1] == sid {
|
||||
// Send to matching session id.
|
||||
wsSendMessage(ev, msg.Fields, ws, writeMutex)
|
||||
}
|
||||
} else if chRes := acl.Resource(ch); acl.Events.AllowAll(chRes, user.AclRole(), wsSubscribePerms) {
|
||||
// Send the message to authorized recipient.
|
||||
// event.AuditDebug([]string{"websocket", "session %s", "%s %s as %s", "granted"}, rid, wsSubscribePerms.String(), chRes.String(), user.AclRole().String())
|
||||
wsSendMessage(msg.Topic(), msg.Fields, ws, writeMutex)
|
||||
}
|
||||
}
|
||||
}
|
57
internal/api/api_ws_publish.go
Normal file
57
internal/api/api_ws_publish.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/search"
|
||||
)
|
||||
|
||||
// EntityEvent represents an entity event type.
|
||||
type EntityEvent string
|
||||
|
||||
const (
|
||||
EntityUpdated EntityEvent = "updated"
|
||||
EntityCreated EntityEvent = "created"
|
||||
EntityDeleted EntityEvent = "deleted"
|
||||
)
|
||||
|
||||
// PublishPhotoEvent publishes updated photo data after changes have been made.
|
||||
func PublishPhotoEvent(ev EntityEvent, uid string, c *gin.Context) {
|
||||
if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil {
|
||||
event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, SessionID(c), string(ev), uid, err)
|
||||
} else {
|
||||
event.PublishEntities("photos", string(ev), result)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishAlbumEvent publishes updated album data after changes have been made.
|
||||
func PublishAlbumEvent(ev EntityEvent, uid string, c *gin.Context) {
|
||||
f := form.SearchAlbums{UID: uid}
|
||||
if result, err := search.Albums(f); err != nil {
|
||||
event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, SessionID(c), string(ev), uid, err)
|
||||
} else {
|
||||
event.PublishEntities("albums", string(ev), result)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishLabelEvent publishes updated label data after changes have been made.
|
||||
func PublishLabelEvent(ev EntityEvent, uid string, c *gin.Context) {
|
||||
f := form.SearchLabels{UID: uid}
|
||||
if result, err := search.Labels(f); err != nil {
|
||||
event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, SessionID(c), string(ev), uid, err)
|
||||
} else {
|
||||
event.PublishEntities("labels", string(ev), result)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishSubjectEvent publishes updated subject data after changes have been made.
|
||||
func PublishSubjectEvent(ev EntityEvent, uid string, c *gin.Context) {
|
||||
f := form.SearchSubjects{UID: uid}
|
||||
if result, err := search.Subjects(f); err != nil {
|
||||
event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, SessionID(c), string(ev), uid, err)
|
||||
} else {
|
||||
event.PublishEntities("subjects", string(ev), result)
|
||||
}
|
||||
}
|
|
@ -1,20 +1,14 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/acl"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/internal/service"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// CreateSession creates a new client session and returns it as JSON if authentication was successful.
|
||||
|
@ -22,107 +16,50 @@ import (
|
|||
// POST /api/v1/session
|
||||
func CreateSession(router *gin.RouterGroup) {
|
||||
router.POST("/session", func(c *gin.Context) {
|
||||
var err error
|
||||
var f form.Login
|
||||
|
||||
if err = c.BindJSON(&f); err != nil {
|
||||
event.AuditWarn([]string{ClientIP(c), "invalid create session request"})
|
||||
if err := c.BindJSON(&f); err != nil {
|
||||
event.AuditWarn([]string{ClientIP(c), "create session", "invalid request", "%s"}, err)
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
|
||||
var user *entity.User
|
||||
var sess *entity.Session
|
||||
var data *entity.SessionData
|
||||
var isNew bool
|
||||
|
||||
id := SessionID(c)
|
||||
|
||||
// Search existing session.
|
||||
if s := Session(id); s != nil {
|
||||
// Find existing session, if any.
|
||||
if s := Session(SessionID(c)); s != nil {
|
||||
// Update existing session.
|
||||
sess = s
|
||||
data = s.Data()
|
||||
user = s.User()
|
||||
} else {
|
||||
data = entity.NewSessionData()
|
||||
user = &entity.User{}
|
||||
id = ""
|
||||
// Create new session.
|
||||
sess = service.Session().New(c)
|
||||
isNew = true
|
||||
}
|
||||
|
||||
conf := service.Config()
|
||||
|
||||
// Share token provided?
|
||||
if f.HasToken() {
|
||||
if shares := data.RedeemToken(f.AuthToken); shares == 0 {
|
||||
event.AuditWarn([]string{ClientIP(c), "share token %s", "invalid"}, clean.LogQuote(f.AuthToken))
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": i18n.Msg(i18n.ErrInvalidLink)})
|
||||
event.LoginError(ClientIP(c), "", UserAgent(c), "invalid share token")
|
||||
return
|
||||
}
|
||||
|
||||
event.AuditInfo([]string{ClientIP(c), "share token %s", "redeemed", "%#v"}, clean.LogQuote(f.AuthToken), data)
|
||||
|
||||
// Upgrade from Unknown to Visitor. Don't downgrade.
|
||||
if user.IsUnknown() {
|
||||
user = &entity.Visitor
|
||||
event.AuditDebug([]string{ClientIP(c), "share token %s", "upgrading session to user role %s"}, clean.LogQuote(f.AuthToken), acl.RoleVisitor.String())
|
||||
}
|
||||
} else if f.HasCredentials() {
|
||||
// If not, authenticate with username and password.
|
||||
userName := f.Name()
|
||||
user = entity.FindUserByName(userName)
|
||||
|
||||
// User found?
|
||||
if user == nil {
|
||||
message := "account not found"
|
||||
event.AuditWarn([]string{ClientIP(c), "login as %s", message}, clean.LogQuote(userName))
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), message)
|
||||
return
|
||||
}
|
||||
|
||||
// Login allowed?
|
||||
if !user.LoginAllowed() {
|
||||
message := "account disabled"
|
||||
event.AuditWarn([]string{ClientIP(c), "login as %s", message}, clean.LogQuote(userName))
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), message)
|
||||
return
|
||||
}
|
||||
|
||||
// Password valid?
|
||||
if user.InvalidPassword(f.Password) {
|
||||
message := "incorrect password"
|
||||
event.AuditErr([]string{ClientIP(c), "login as %s", message}, clean.LogQuote(userName))
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), message)
|
||||
return
|
||||
} else {
|
||||
event.AuditInfo([]string{ClientIP(c), "login as %s", "succeeded"}, clean.LogQuote(userName))
|
||||
event.LoginSuccess(ClientIP(c), f.Name(), UserAgent(c))
|
||||
}
|
||||
} else {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), "invalid request")
|
||||
// Sign in and save session.
|
||||
if err := sess.SignIn(f, c); err != nil {
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if sess, err = service.Session().Save(sess); err != nil {
|
||||
event.AuditErr([]string{ClientIP(c), "%s"}, err)
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
}
|
||||
|
||||
// Save session.
|
||||
if sess, err = service.Session().Save(id, user, c, data); err != nil {
|
||||
event.AuditWarn([]string{ClientIP(c), "%s"}, err)
|
||||
} else if sess == nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
|
||||
return
|
||||
} else if isNew {
|
||||
event.AuditInfo([]string{ClientIP(c), "session %s", "created"}, sess.RefID)
|
||||
} else {
|
||||
event.AuditInfo([]string{ClientIP(c), "session %s", "updated"}, sess.RefID)
|
||||
}
|
||||
|
||||
// Log event.
|
||||
event.AuditInfo([]string{ClientIP(c), "session %s", "created"}, sess.RefID)
|
||||
|
||||
// Add session id to response headers.
|
||||
AddSessionHeader(c, sess.ID)
|
||||
|
||||
// Get config values for use by the JavaScript UI and other clients.
|
||||
var clientConfig config.ClientConfig
|
||||
|
||||
if sess.User().IsVisitor() {
|
||||
if conf := service.Config(); sess.User().IsVisitor() {
|
||||
clientConfig = conf.ClientShare()
|
||||
} else if sess.User().IsRegistered() {
|
||||
clientConfig = conf.ClientSession(sess)
|
||||
|
@ -131,6 +68,6 @@ func CreateSession(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
// Send JSON response with user information, session data, and client config values.
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": sess.ID, "user": sess.User(), "data": sess.Data(), "config": clientConfig})
|
||||
c.JSON(sess.HttpStatus(), gin.H{"status": "ok", "id": sess.ID, "user": sess.User(), "data": sess.Data(), "config": clientConfig})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -41,13 +41,32 @@ func TestCreateSession(t *testing.T) {
|
|||
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": 123, "password": "xxx"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, r.Code)
|
||||
})
|
||||
t.Run("InvalidShareToken", func(t *testing.T) {
|
||||
t.Run("PublicInvalidToken", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateSession(router)
|
||||
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "admin", "password": "photoprism", "token": "xxx"}`)
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
t.Run("ValidShareToken", func(t *testing.T) {
|
||||
t.Run("AdminInvalidToken", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
// CreateSession(router)
|
||||
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, sessId)
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
t.Run("VisitorInvalidToken", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateSession(router)
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "xxx"}`, "345346")
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
t.Run("AdminValidToken", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
|
||||
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"token": "1jxf3jfn2k"}`, sessId)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
t.Run("PublicValidToken", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateSession(router)
|
||||
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "admin", "password": "photoprism", "token": "1jxf3jfn2k"}`)
|
||||
|
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
@ -12,50 +13,46 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// Shares handles link share
|
||||
//
|
||||
// GET /s/:token/...
|
||||
func Shares(router *gin.RouterGroup) {
|
||||
router.GET("/:token", func(c *gin.Context) {
|
||||
conf := service.Config()
|
||||
|
||||
token := clean.Token(c.Param("token"))
|
||||
|
||||
links := entity.FindValidLinks(token, "")
|
||||
|
||||
if len(links) == 0 {
|
||||
log.Warn("share: invalid token")
|
||||
c.Redirect(http.StatusTemporaryRedirect, "/")
|
||||
log.Debugf("share: invalid token")
|
||||
c.Redirect(http.StatusTemporaryRedirect, conf.BaseUri(""))
|
||||
return
|
||||
}
|
||||
|
||||
clientConfig := conf.ClientShare()
|
||||
clientConfig.SiteUrl = fmt.Sprintf("%ss/%s", clientConfig.SiteUrl, token)
|
||||
|
||||
c.HTML(http.StatusOK, "share.tmpl", gin.H{"config": clientConfig})
|
||||
uri := conf.BaseUri("/albums")
|
||||
c.HTML(http.StatusOK, "share.tmpl", gin.H{"shared": gin.H{"token": token, "uri": uri}, "config": clientConfig})
|
||||
})
|
||||
|
||||
router.GET("/:token/:share", func(c *gin.Context) {
|
||||
router.GET("/:token/:shared", func(c *gin.Context) {
|
||||
conf := service.Config()
|
||||
|
||||
token := clean.Token(c.Param("token"))
|
||||
share := clean.Token(c.Param("share"))
|
||||
shared := clean.Token(c.Param("shared"))
|
||||
|
||||
links := entity.FindValidLinks(token, share)
|
||||
links := entity.FindValidLinks(token, shared)
|
||||
|
||||
if len(links) < 1 {
|
||||
log.Warn("share: invalid token or share")
|
||||
c.Redirect(http.StatusTemporaryRedirect, "/")
|
||||
log.Debugf("share: invalid token or slug")
|
||||
c.Redirect(http.StatusTemporaryRedirect, conf.BaseUri(""))
|
||||
return
|
||||
}
|
||||
|
||||
uid := links[0].ShareUID
|
||||
clientConfig := conf.ClientShare()
|
||||
|
||||
if uid != share {
|
||||
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%ss/%s/%s", clientConfig.SiteUrl, token, uid))
|
||||
return
|
||||
}
|
||||
|
||||
clientConfig.SiteUrl = fmt.Sprintf("%ss/%s/%s", clientConfig.SiteUrl, token, uid)
|
||||
clientConfig.SiteUrl = fmt.Sprintf("%s/%s", clientConfig.SiteUrl, path.Join("s", token, uid))
|
||||
clientConfig.SitePreview = fmt.Sprintf("%s/preview", clientConfig.SiteUrl)
|
||||
|
||||
if a, err := query.AlbumByUID(uid); err == nil {
|
||||
|
@ -66,6 +63,8 @@ func Shares(router *gin.RouterGroup) {
|
|||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "share.tmpl", gin.H{"config": clientConfig})
|
||||
uri := conf.BaseUri(path.Join("/albums", uid, shared))
|
||||
|
||||
c.HTML(http.StatusOK, "share.tmpl", gin.H{"shared": gin.H{"token": token, "uri": uri}, "config": clientConfig})
|
||||
})
|
||||
}
|
|
@ -43,7 +43,11 @@ func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, pas
|
|||
// Performs authenticated API request with empty request body.
|
||||
func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.ResponseRecorder {
|
||||
req, _ := http.NewRequest(method, path, nil)
|
||||
req.Header.Add(session.Header, sess)
|
||||
|
||||
if sess != "" {
|
||||
req.Header.Add(session.Header, sess)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
|
@ -54,7 +58,10 @@ func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.R
|
|||
func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, sess string) *httptest.ResponseRecorder {
|
||||
reader := strings.NewReader(body)
|
||||
req, _ := http.NewRequest(method, path, reader)
|
||||
req.Header.Add(session.Header, sess)
|
||||
|
||||
if sess != "" {
|
||||
req.Header.Add(session.Header, sess)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
|
|
@ -40,7 +40,7 @@ func GetSettings(router *gin.RouterGroup) {
|
|||
// POST /api/v1/settings
|
||||
func SaveSettings(router *gin.RouterGroup) {
|
||||
router.POST("/settings", func(c *gin.Context) {
|
||||
s := AuthAny(c, acl.ResourceSettings, acl.Permissions{acl.ActionUpdate, acl.ActionManage})
|
||||
s := AuthAny(c, acl.ResourceSettings, acl.Permissions{acl.ActionView, acl.ActionUpdate, acl.ActionManage})
|
||||
|
||||
// Abort if permission was not granted.
|
||||
if s.Abort(c) {
|
||||
|
@ -56,8 +56,8 @@ func SaveSettings(router *gin.RouterGroup) {
|
|||
|
||||
var settings *customize.Settings
|
||||
|
||||
// Only admins can change the global config.
|
||||
if s.User().IsAdmin() {
|
||||
// Only admins may change the global config.
|
||||
settings = conf.Settings()
|
||||
|
||||
if err := c.BindJSON(settings); err != nil {
|
||||
|
@ -73,6 +73,7 @@ func SaveSettings(router *gin.RouterGroup) {
|
|||
|
||||
UpdateClientConfig()
|
||||
} else {
|
||||
// Apply to user preferences and keep current values if unspecified.
|
||||
user := s.User()
|
||||
|
||||
if user == nil {
|
||||
|
@ -87,8 +88,11 @@ func SaveSettings(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
// Apply to user preferences and keep current values if unspecified.
|
||||
if err := user.Settings().Apply(settings).Save(); err != nil {
|
||||
if acl.Resources.DenyAll(acl.ResourceSettings, s.User().AclRole(), acl.Permissions{acl.ActionUpdate, acl.ActionManage}) {
|
||||
event.InfoMsg(i18n.MsgSettingsSaved)
|
||||
c.JSON(http.StatusOK, user.Settings().Apply(settings).ApplyTo(conf.Settings().ApplyACL(acl.Resources, user.AclRole())))
|
||||
return
|
||||
} else if err := user.Settings().Apply(settings).Save(); err != nil {
|
||||
log.Debugf("config: %s (save user settings)", err)
|
||||
AbortSaveFailed(c)
|
||||
return
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/search"
|
||||
)
|
||||
|
||||
type EntityEvent string
|
||||
|
||||
const (
|
||||
EntityUpdated EntityEvent = "updated"
|
||||
EntityCreated EntityEvent = "created"
|
||||
EntityDeleted EntityEvent = "deleted"
|
||||
EntityReacted EntityEvent = "reacted"
|
||||
)
|
||||
|
||||
func PublishPhotoEvent(e EntityEvent, uid string, c *gin.Context) {
|
||||
if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil {
|
||||
log.Warnf("search: %s", err)
|
||||
AbortUnexpected(c)
|
||||
} else {
|
||||
event.PublishEntities("photos", string(e), result)
|
||||
}
|
||||
}
|
||||
|
||||
func PublishAlbumEvent(e EntityEvent, uid string, c *gin.Context) {
|
||||
f := form.SearchAlbums{UID: uid}
|
||||
result, err := search.Albums(f)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
AbortUnexpected(c)
|
||||
return
|
||||
}
|
||||
|
||||
event.PublishEntities("albums", string(e), result)
|
||||
}
|
||||
|
||||
func PublishLabelEvent(e EntityEvent, uid string, c *gin.Context) {
|
||||
f := form.SearchLabels{UID: uid}
|
||||
result, err := search.Labels(f)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
AbortUnexpected(c)
|
||||
return
|
||||
}
|
||||
|
||||
event.PublishEntities("labels", string(e), result)
|
||||
}
|
||||
|
||||
func PublishSubjectEvent(e EntityEvent, uid string, c *gin.Context) {
|
||||
f := form.SearchSubjects{UID: uid}
|
||||
result, err := search.Subjects(f)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
AbortUnexpected(c)
|
||||
return
|
||||
}
|
||||
|
||||
event.PublishEntities("subjects", string(e), result)
|
||||
}
|
|
@ -37,10 +37,10 @@ func DeleteFile(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
photoUID := clean.UID(c.Param("uid"))
|
||||
fileUID := clean.UID(c.Param("file_uid"))
|
||||
photoUid := clean.UID(c.Param("uid"))
|
||||
fileUid := clean.UID(c.Param("file_uid"))
|
||||
|
||||
file, err := query.FileByUID(fileUID)
|
||||
file, err := query.FileByUID(fileUid)
|
||||
|
||||
// Found?
|
||||
if err != nil {
|
||||
|
@ -85,12 +85,12 @@ func DeleteFile(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
// Notify clients by publishing events.
|
||||
PublishPhotoEvent(EntityUpdated, photoUID, c)
|
||||
PublishPhotoEvent(EntityUpdated, photoUid, c)
|
||||
|
||||
// Show translated success message.
|
||||
event.SuccessMsg(i18n.MsgFileDeleted)
|
||||
|
||||
if p, err := query.PhotoPreloadByUID(photoUID); err != nil {
|
||||
if p, err := query.PhotoPreloadByUID(photoUid); err != nil {
|
||||
AbortEntityNotFound(c)
|
||||
return
|
||||
} else {
|
||||
|
|
|
@ -105,7 +105,7 @@ func StartImport(router *gin.RouterGroup) {
|
|||
|
||||
// Set user UID if known.
|
||||
if s.UserUID != "" {
|
||||
opt.OwnerUID = s.UserUID
|
||||
opt.UserUID = s.UserUID
|
||||
}
|
||||
|
||||
// Start import.
|
||||
|
|
|
@ -118,7 +118,7 @@ func CreateLink(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
link := entity.NewUserLink(uid, f.CanComment, f.CanEdit, s.UserUID)
|
||||
link := entity.NewUserLink(uid, s.UserUID)
|
||||
|
||||
link.SetSlug(f.ShareSlug)
|
||||
link.MaxViews = f.MaxViews
|
||||
|
|
|
@ -32,10 +32,7 @@ func TestCreateAlbumLink(t *testing.T) {
|
|||
assert.NotEmpty(t, link.LinkUID)
|
||||
assert.NotEmpty(t, link.ShareUID)
|
||||
assert.NotEmpty(t, link.LinkToken)
|
||||
assert.Equal(t, true, link.CanEdit)
|
||||
assert.Equal(t, 0, link.LinkExpires)
|
||||
assert.False(t, link.CanComment)
|
||||
assert.True(t, link.CanEdit)
|
||||
})
|
||||
t.Run("album does not exist", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
|
|
|
@ -24,15 +24,15 @@ func TestUpdateMarker(t *testing.T) {
|
|||
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
|
||||
photoUID := gjson.Get(r.Body.String(), "UID").String()
|
||||
fileUID := gjson.Get(r.Body.String(), "Files.0.UID").String()
|
||||
markerUID := gjson.Get(r.Body.String(), "Files.0.Markers.0.UID").String()
|
||||
photoUid := gjson.Get(r.Body.String(), "UID").String()
|
||||
fileUid := gjson.Get(r.Body.String(), "Files.0.UID").String()
|
||||
markerUid := gjson.Get(r.Body.String(), "Files.0.Markers.0.UID").String()
|
||||
|
||||
assert.NotEmpty(t, photoUID)
|
||||
assert.NotEmpty(t, fileUID)
|
||||
assert.NotEmpty(t, markerUID)
|
||||
assert.NotEmpty(t, photoUid)
|
||||
assert.NotEmpty(t, fileUid)
|
||||
assert.NotEmpty(t, markerUid)
|
||||
|
||||
u := fmt.Sprintf("/api/v1/markers/%s", markerUID)
|
||||
u := fmt.Sprintf("/api/v1/markers/%s", markerUid)
|
||||
|
||||
var m = form.Marker{
|
||||
SubjSrc: "manual",
|
||||
|
@ -193,15 +193,15 @@ func TestClearMarkerSubject(t *testing.T) {
|
|||
t.Fatal("body is empty")
|
||||
}
|
||||
|
||||
photoUID := gjson.Get(photoResp.Body.String(), "UID").String()
|
||||
fileUID := gjson.Get(photoResp.Body.String(), "Files.0.UID").String()
|
||||
markerUID := gjson.Get(photoResp.Body.String(), "Files.0.Markers.0.UID").String()
|
||||
photoUid := gjson.Get(photoResp.Body.String(), "UID").String()
|
||||
fileUid := gjson.Get(photoResp.Body.String(), "Files.0.UID").String()
|
||||
markerUid := gjson.Get(photoResp.Body.String(), "Files.0.Markers.0.UID").String()
|
||||
|
||||
assert.NotEmpty(t, photoUID)
|
||||
assert.NotEmpty(t, fileUID)
|
||||
assert.NotEmpty(t, markerUID)
|
||||
assert.NotEmpty(t, photoUid)
|
||||
assert.NotEmpty(t, fileUid)
|
||||
assert.NotEmpty(t, markerUid)
|
||||
|
||||
u := fmt.Sprintf("/api/v1/markers/%s/subject", markerUID)
|
||||
u := fmt.Sprintf("/api/v1/markers/%s/subject", markerUid)
|
||||
|
||||
// t.Logf("DELETE %s", u)
|
||||
|
||||
|
|
|
@ -34,8 +34,8 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
conf := service.Config()
|
||||
fileUID := clean.UID(c.Param("file_uid"))
|
||||
file, err := query.FileByUID(fileUID)
|
||||
fileUid := clean.UID(c.Param("file_uid"))
|
||||
file, err := query.FileByUID(fileUid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("photo: %s (unstack)", err)
|
||||
|
@ -67,13 +67,13 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
|||
AbortEntityNotFound(c)
|
||||
return
|
||||
} else if file.Photo == nil {
|
||||
log.Errorf("photo: cannot find photo for file uid %s (unstack)", fileUID)
|
||||
log.Errorf("photo: cannot find photo for file uid %s (unstack)", fileUid)
|
||||
AbortEntityNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
stackPhoto := *file.Photo
|
||||
ownerUID := stackPhoto.OwnerUID
|
||||
createdBy := stackPhoto.CreatedBy
|
||||
stackPrimary, err := stackPhoto.PrimaryFile()
|
||||
|
||||
if err != nil {
|
||||
|
@ -126,7 +126,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
// Create new photo, also flagged as unstacked / not stackable.
|
||||
newPhoto := entity.NewUserPhoto(false, ownerUID)
|
||||
newPhoto := entity.NewUserPhoto(false, createdBy)
|
||||
newPhoto.PhotoPath = unstackFile.RootRelPath()
|
||||
newPhoto.PhotoName = unstackFile.BasePrefix(false)
|
||||
|
||||
|
|
|
@ -244,8 +244,8 @@ func PhotoPrimary(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
uid := clean.UID(c.Param("uid"))
|
||||
fileUID := clean.UID(c.Param("file_uid"))
|
||||
err := query.SetPhotoPrimary(uid, fileUID)
|
||||
fileUid := clean.UID(c.Param("file_uid"))
|
||||
err := query.SetPhotoPrimary(uid, fileUid)
|
||||
|
||||
if err != nil {
|
||||
AbortEntityNotFound(c)
|
||||
|
|
|
@ -27,12 +27,12 @@ import (
|
|||
// GET /s/:token/:uid/preview
|
||||
// TODO: Proof of concept, needs refactoring.
|
||||
func SharePreview(router *gin.RouterGroup) {
|
||||
router.GET("/:token/:share/preview", func(c *gin.Context) {
|
||||
router.GET("/:token/:shared/preview", func(c *gin.Context) {
|
||||
conf := service.Config()
|
||||
|
||||
token := clean.Token(c.Param("token"))
|
||||
share := clean.Token(c.Param("share"))
|
||||
links := entity.FindLinks(token, share)
|
||||
shared := clean.Token(c.Param("shared"))
|
||||
links := entity.FindLinks(token, shared)
|
||||
|
||||
if len(links) != 1 {
|
||||
log.Warn("share: invalid token (preview)")
|
||||
|
@ -48,17 +48,17 @@ func SharePreview(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
previewFilename := fmt.Sprintf("%s/%s.jpg", thumbPath, share)
|
||||
previewFilename := fmt.Sprintf("%s/%s.jpg", thumbPath, shared)
|
||||
yesterday := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
if info, err := os.Stat(previewFilename); err != nil {
|
||||
log.Debugf("share: creating new preview for %s", clean.Log(share))
|
||||
log.Debugf("share: creating new preview for %s", clean.Log(shared))
|
||||
} else if info.ModTime().After(yesterday) {
|
||||
log.Debugf("share: using cached preview for %s", clean.Log(share))
|
||||
log.Debugf("share: using cached preview for %s", clean.Log(shared))
|
||||
c.File(previewFilename)
|
||||
return
|
||||
} else if err := os.Remove(previewFilename); err != nil {
|
||||
log.Errorf("share: could not remove old preview of %s", clean.Log(share))
|
||||
log.Errorf("share: could not remove old preview of %s", clean.Log(shared))
|
||||
c.Redirect(http.StatusTemporaryRedirect, conf.SitePreview())
|
||||
return
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ func SharePreview(router *gin.RouterGroup) {
|
|||
var f form.SearchPhotos
|
||||
|
||||
// Covers may only contain public content in shared albums.
|
||||
f.Album = share
|
||||
f.Album = shared
|
||||
f.Public = true
|
||||
f.Private = false
|
||||
f.Hidden = false
|
||||
|
|
|
@ -300,7 +300,7 @@ func (c *Config) ClientShare() ClientConfig {
|
|||
Edition: c.Edition(),
|
||||
BaseUri: c.BaseUri(""),
|
||||
StaticUri: c.StaticUri(),
|
||||
CssUri: a.ShareCssUri(),
|
||||
CssUri: a.AppCssUri(),
|
||||
JsUri: a.ShareJsUri(),
|
||||
ApiUri: c.ApiUri(),
|
||||
ContentUri: c.ContentUri(),
|
||||
|
@ -324,10 +324,10 @@ func (c *Config) ClientShare() ClientConfig {
|
|||
Test: c.Test(),
|
||||
Demo: c.Demo(),
|
||||
Sponsor: c.Sponsor(),
|
||||
ReadOnly: true,
|
||||
ReadOnly: c.ReadOnly(),
|
||||
UploadNSFW: c.UploadNSFW(),
|
||||
Public: true,
|
||||
Experimental: false,
|
||||
Public: c.Public(),
|
||||
Experimental: c.Experimental(),
|
||||
Colors: colors.All.List(),
|
||||
Thumbs: Thumbs,
|
||||
Status: c.Hub().Status,
|
||||
|
|
|
@ -69,8 +69,8 @@ func TestConfig_ClientShareConfig(t *testing.T) {
|
|||
result := config.ClientShare()
|
||||
assert.IsType(t, ClientConfig{}, result)
|
||||
assert.Equal(t, true, result.Public)
|
||||
assert.Equal(t, false, result.Experimental)
|
||||
assert.Equal(t, true, result.ReadOnly)
|
||||
assert.Equal(t, true, result.Experimental)
|
||||
assert.Equal(t, false, result.ReadOnly)
|
||||
}
|
||||
|
||||
func TestConfig_ClientRoleConfig(t *testing.T) {
|
||||
|
@ -141,12 +141,12 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
|
|||
Estimates: true,
|
||||
Favorites: false,
|
||||
Files: false,
|
||||
Folders: false,
|
||||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
Moments: false,
|
||||
Moments: true,
|
||||
People: false,
|
||||
Places: true,
|
||||
Private: false,
|
||||
|
@ -268,7 +268,8 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
|
|||
assert.False(t, f.Search)
|
||||
assert.False(t, f.Videos)
|
||||
assert.True(t, f.Albums)
|
||||
assert.False(t, f.Moments)
|
||||
assert.True(t, f.Moments)
|
||||
assert.True(t, f.Folders)
|
||||
assert.False(t, f.Labels)
|
||||
assert.False(t, f.People)
|
||||
assert.False(t, f.Settings)
|
||||
|
|
|
@ -42,7 +42,7 @@ func (s *Settings) ApplyACL(list acl.ACL, role acl.Role) *Settings {
|
|||
// Settings.
|
||||
m.Features.Account = s.Features.Account && list.Allow(acl.ResourcePassword, role, acl.ActionUpdate)
|
||||
m.Features.Advanced = s.Features.Advanced && list.Allow(acl.ResourceConfig, role, acl.ActionManage)
|
||||
m.Features.Settings = s.Features.Settings && list.Allow(acl.ResourceSettings, role, acl.ActionUpdate)
|
||||
m.Features.Settings = s.Features.Settings && list.AllowAny(acl.ResourceSettings, role, acl.Permissions{acl.ActionUpdate})
|
||||
m.Features.Sync = s.Features.Sync && list.Allow(acl.ResourceAccounts, role, acl.ActionManage)
|
||||
|
||||
return &m
|
||||
|
|
|
@ -66,12 +66,12 @@ func TestSettings_ApplyACL(t *testing.T) {
|
|||
Estimates: true,
|
||||
Favorites: false,
|
||||
Files: false,
|
||||
Folders: false,
|
||||
Folders: true,
|
||||
Import: false,
|
||||
Labels: false,
|
||||
Library: false,
|
||||
Logs: false,
|
||||
Moments: false,
|
||||
Moments: true,
|
||||
People: false,
|
||||
Places: true,
|
||||
Private: false,
|
||||
|
|
|
@ -218,7 +218,7 @@ func (m *Account) Update(attr string, value interface{}) error {
|
|||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Account) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -32,8 +32,8 @@ type Albums []Album
|
|||
// Album represents a photo album
|
||||
type Album struct {
|
||||
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
||||
AlbumUID string `gorm:"type:VARBINARY(64);unique_index;" json:"UID" yaml:"UID"`
|
||||
ParentUID string `gorm:"type:VARBINARY(64);default:'';" json:"ParentUID,omitempty" yaml:"ParentUID,omitempty"`
|
||||
AlbumUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
||||
ParentUID string `gorm:"type:VARBINARY(42);default:'';" json:"ParentUID,omitempty" yaml:"ParentUID,omitempty"`
|
||||
AlbumSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug"`
|
||||
AlbumPath string `gorm:"type:VARBINARY(1024);index;" json:"Path,omitempty" yaml:"Path,omitempty"`
|
||||
AlbumType string `gorm:"type:VARBINARY(8);default:'album';" json:"Type" yaml:"Type,omitempty"`
|
||||
|
@ -55,9 +55,10 @@ type Album struct {
|
|||
AlbumPrivate bool `json:"Private" yaml:"Private,omitempty"`
|
||||
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb" yaml:"Thumb,omitempty"`
|
||||
ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
|
||||
OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"`
|
||||
CreatedBy string `gorm:"type:VARBINARY(42);index" json:"CreatedBy,omitempty" yaml:"CreatedBy,omitempty"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"`
|
||||
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
||||
DeletedAt *time.Time `sql:"index" json:"DeletedAt" yaml:"DeletedAt,omitempty"`
|
||||
Photos PhotoAlbums `gorm:"foreignkey:AlbumUID;association_foreignkey:AlbumUID;" json:"-" yaml:"Photos,omitempty"`
|
||||
}
|
||||
|
@ -85,43 +86,43 @@ func AddPhotoToAlbums(uid string, albums []string) (err error) {
|
|||
}
|
||||
|
||||
// AddPhotoToUserAlbums adds a photo UID to multiple albums and automatically creates them as a user if needed.
|
||||
func AddPhotoToUserAlbums(uid string, albums []string, userUID string) (err error) {
|
||||
if uid == "" || len(albums) == 0 {
|
||||
func AddPhotoToUserAlbums(photoUid string, albums []string, userUid string) (err error) {
|
||||
if photoUid == "" || len(albums) == 0 {
|
||||
// Do nothing.
|
||||
return nil
|
||||
}
|
||||
|
||||
if !rnd.IsUID(uid, PhotoUID) {
|
||||
return fmt.Errorf("album: invalid photo uid %s", uid)
|
||||
if !rnd.IsUID(photoUid, PhotoUID) {
|
||||
return fmt.Errorf("album: can not add invalid photo uid %s", clean.Log(photoUid))
|
||||
}
|
||||
|
||||
for _, album := range albums {
|
||||
var aUID string
|
||||
var albumUid string
|
||||
|
||||
if album == "" {
|
||||
log.Debugf("album: empty album identifier while adding photo %s", uid)
|
||||
log.Debugf("album: cannot add photo uid %s because album id was not specified", clean.Log(photoUid))
|
||||
continue
|
||||
}
|
||||
|
||||
if rnd.IsUID(album, AlbumUID) {
|
||||
aUID = album
|
||||
albumUid = album
|
||||
} else {
|
||||
a := NewUserAlbum(album, AlbumDefault, userUID)
|
||||
a := NewUserAlbum(album, AlbumDefault, userUid)
|
||||
|
||||
if found := a.Find(); found != nil {
|
||||
aUID = found.AlbumUID
|
||||
albumUid = found.AlbumUID
|
||||
} else if err = a.Create(); err == nil {
|
||||
aUID = a.AlbumUID
|
||||
albumUid = a.AlbumUID
|
||||
} else {
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), uid)
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
|
||||
}
|
||||
}
|
||||
|
||||
if aUID != "" {
|
||||
entry := PhotoAlbum{AlbumUID: aUID, PhotoUID: uid, Hidden: false}
|
||||
if albumUid != "" {
|
||||
entry := PhotoAlbum{AlbumUID: albumUid, PhotoUID: photoUid, Hidden: false}
|
||||
|
||||
if err = entry.Save(); err != nil {
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), uid)
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -135,7 +136,7 @@ func NewAlbum(albumTitle, albumType string) *Album {
|
|||
}
|
||||
|
||||
// NewUserAlbum creates a new album owned by a user.
|
||||
func NewUserAlbum(albumTitle, albumType, userUID string) *Album {
|
||||
func NewUserAlbum(albumTitle, albumType, userUid string) *Album {
|
||||
now := TimeStamp()
|
||||
|
||||
// Set default type.
|
||||
|
@ -145,11 +146,11 @@ func NewUserAlbum(albumTitle, albumType, userUID string) *Album {
|
|||
|
||||
// Set default values.
|
||||
result := &Album{
|
||||
OwnerUID: userUID,
|
||||
AlbumOrder: SortOrderOldest,
|
||||
AlbumType: albumType,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedBy: userUid,
|
||||
}
|
||||
|
||||
// Set album title.
|
||||
|
@ -372,11 +373,13 @@ func (m *Album) Find() *Album {
|
|||
|
||||
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
||||
func (m *Album) BeforeCreate(scope *gorm.Scope) error {
|
||||
if rnd.IsUnique(m.AlbumUID, AlbumUID) {
|
||||
if rnd.IsUID(m.AlbumUID, AlbumUID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return scope.SetColumn("AlbumUID", rnd.GenerateUID(AlbumUID))
|
||||
m.AlbumUID = rnd.GenerateUID(AlbumUID)
|
||||
|
||||
return scope.SetColumn("AlbumUID", m.AlbumUID)
|
||||
}
|
||||
|
||||
// String returns the id or name as string.
|
||||
|
@ -525,6 +528,7 @@ func (m *Album) SaveForm(f form.Album) error {
|
|||
|
||||
// Update sets a new value for a database column.
|
||||
func (m *Album) Update(attr string, value interface{}) error {
|
||||
|
||||
return UnscopedDb().Model(m).Update(attr, value).Error
|
||||
}
|
||||
|
||||
|
@ -555,9 +559,14 @@ func (m *Album) UpdateFolder(albumPath, albumFilter string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Album) Save() error {
|
||||
return Db().Save(m).Error
|
||||
if err := Db().Save(m).Error; err != nil {
|
||||
return err
|
||||
} else {
|
||||
event.PublishUserEntities("albums", event.EntityUpdated, []*Album{m}, m.CreatedBy)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create inserts a new row to the database.
|
||||
|
@ -567,6 +576,7 @@ func (m *Album) Create() error {
|
|||
}
|
||||
|
||||
m.PublishCountChange(1)
|
||||
event.PublishUserEntities("albums", event.EntityCreated, []*Album{m}, m.CreatedBy)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -598,6 +608,7 @@ func (m *Album) Delete() error {
|
|||
}
|
||||
|
||||
m.PublishCountChange(-1)
|
||||
event.EntitiesDeleted("albums", []string{m.AlbumUID})
|
||||
|
||||
return DeleteShareLinks(m.AlbumUID)
|
||||
}
|
||||
|
@ -612,6 +623,7 @@ func (m *Album) DeletePermanently() error {
|
|||
|
||||
if !wasDeleted {
|
||||
m.PublishCountChange(-1)
|
||||
event.EntitiesDeleted("albums", []string{m.AlbumUID})
|
||||
}
|
||||
|
||||
return DeleteShareLinks(m.AlbumUID)
|
||||
|
@ -639,6 +651,7 @@ func (m *Album) Restore() error {
|
|||
m.DeletedAt = nil
|
||||
|
||||
m.PublishCountChange(1)
|
||||
event.PublishUserEntities("albums", event.EntityCreated, []*Album{m}, m.CreatedBy)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
56
internal/entity/album_auth.go
Normal file
56
internal/entity/album_auth.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package entity
|
||||
|
||||
import "github.com/photoprism/photoprism/internal/event"
|
||||
|
||||
// AlbumAuth represents the ownership of an Album and the corresponding permissions.
|
||||
type AlbumAuth struct {
|
||||
UID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false" json:"UID" yaml:"UID"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;index" json:"UserUID" yaml:"UserUID"`
|
||||
TeamUID string `gorm:"type:VARBINARY(42);index" json:"TeamUID" yaml:"TeamUID"`
|
||||
Perm uint `json:"Perm,omitempty" yaml:"Perm,omitempty"`
|
||||
Changed int64 `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the database table name.
|
||||
func (AlbumAuth) TableName() string {
|
||||
return "albums_auth_dev"
|
||||
}
|
||||
|
||||
// NewAlbumAuth creates a new entity model.
|
||||
func NewAlbumAuth(uid, userUid, teamUid string, perm uint) *AlbumAuth {
|
||||
result := &AlbumAuth{
|
||||
UID: uid,
|
||||
UserUID: userUid,
|
||||
TeamUID: teamUid,
|
||||
Perm: perm,
|
||||
Changed: TimeStamp().Unix(),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Create inserts a new record into the database.
|
||||
func (m *AlbumAuth) Create() error {
|
||||
m.Changed = TimeStamp().Unix()
|
||||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *AlbumAuth) Save() error {
|
||||
m.Changed = TimeStamp().Unix()
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
||||
// FirstOrCreateAlbumUser returns the existing record or inserts a new record if it does not already exist.
|
||||
func FirstOrCreateAlbumUser(m *AlbumAuth) *AlbumAuth {
|
||||
found := AlbumAuth{}
|
||||
|
||||
if err := Db().Where("uid = ?", m.UID).First(&found).Error; err == nil {
|
||||
return &found
|
||||
} else if err = m.Create(); err != nil {
|
||||
event.AuditErr([]string{"photo %s", "failed to set owner and permissions", "%s"}, m.UID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
|
@ -6,13 +6,12 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
@ -20,6 +19,7 @@ import (
|
|||
// SessionPrefix for RefID.
|
||||
const (
|
||||
SessionPrefix = "sess"
|
||||
UnknownIP = "0.0.0.0"
|
||||
)
|
||||
|
||||
// Sessions represents a list of sessions.
|
||||
|
@ -28,12 +28,13 @@ type Sessions []Session
|
|||
// Session represents a User session.
|
||||
type Session struct {
|
||||
ID string `gorm:"type:VARBINARY(2048);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
|
||||
Status int `gorm:"-"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"AuthMethod,omitempty" yaml:"AuthMethod,omitempty"`
|
||||
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"AuthScope,omitempty" yaml:"AuthScope,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
|
||||
UserUID string `gorm:"type:VARBINARY(64);index;default:'';" json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
|
||||
Status int `json:"Status,omitempty" yaml:"-"`
|
||||
AuthDomain string `gorm:"type:VARBINARY(253);default:'';" json:"-" yaml:"AuthDomain,omitempty"`
|
||||
AuthMethod string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthMethod,omitempty"`
|
||||
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"-" yaml:"AuthProvider,omitempty"`
|
||||
AuthScope string `gorm:"size:1024;default:'';" json:"-" yaml:"AuthScope,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"-" yaml:"AuthID,omitempty"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID,omitempty" yaml:"UserUID,omitempty"`
|
||||
UserName string `gorm:"size:64;index;" json:"UserName,omitempty" yaml:"UserName,omitempty"`
|
||||
user *User `gorm:"-"`
|
||||
PreviewToken string `gorm:"type:VARBINARY(64);column:preview_token;default:'';" json:"-" yaml:"-"`
|
||||
|
@ -41,16 +42,17 @@ type Session struct {
|
|||
AccessToken string `gorm:"type:VARBINARY(4096);column:access_token;default:'';" json:"-" yaml:"-"`
|
||||
RefreshToken string `gorm:"type:VARBINARY(512);column:refresh_token;default:'';" json:"-" yaml:"-"`
|
||||
IdToken string `gorm:"type:VARBINARY(1024);column:id_token;default:'';" json:"IdToken,omitempty" yaml:"IdToken,omitempty"`
|
||||
UserAgent string `gorm:"size:512;" json:"UserAgent,omitempty" yaml:"UserAgent,omitempty"`
|
||||
ClientIP string `gorm:"size:64;column:client_ip;" json:"ClientIP,omitempty" yaml:"ClientIP,omitempty"`
|
||||
UserAgent string `gorm:"size:512;" json:"-" yaml:"UserAgent,omitempty"`
|
||||
ClientIP string `gorm:"size:64;column:client_ip;" json:"-" yaml:"ClientIP,omitempty"`
|
||||
LoginIP string `gorm:"size:64;column:login_ip" json:"-" yaml:"-"`
|
||||
LoginAt time.Time `json:"-" yaml:"-"`
|
||||
MaxAge time.Duration `json:"MaxAge,omitempty" yaml:"MaxAge,omitempty"`
|
||||
Timeout time.Duration `json:"Timeout,omitempty" yaml:"Timeout,omitempty"`
|
||||
DataJSON json.RawMessage `gorm:"type:VARBINARY(4096);" json:"Data,omitempty" yaml:"Data,omitempty"`
|
||||
data *SessionData `gorm:"-"`
|
||||
RefID string `gorm:"type:VARBINARY(16);default:'';" json:"-" yaml:"-"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt"`
|
||||
ExpiresAt time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the entity table name.
|
||||
|
@ -59,13 +61,25 @@ func (Session) TableName() string {
|
|||
}
|
||||
|
||||
// NewSession creates a new session and returns it.
|
||||
func NewSession(expiresAfter time.Duration) (m *Session) {
|
||||
func NewSession(maxAge, timeout time.Duration) (m *Session) {
|
||||
created := TimeStamp()
|
||||
|
||||
// Makes no sense for the timeout to be longer than the max age.
|
||||
if timeout >= maxAge {
|
||||
maxAge = timeout
|
||||
timeout = 0
|
||||
} else if maxAge == 0 {
|
||||
// Set maxAge to default if not specified.
|
||||
maxAge = time.Hour * 24 * 7
|
||||
}
|
||||
|
||||
m = &Session{
|
||||
ID: rnd.SessionID(),
|
||||
MaxAge: maxAge,
|
||||
Timeout: timeout,
|
||||
RefID: rnd.RefID(SessionPrefix),
|
||||
CreatedAt: TimeStamp(),
|
||||
UpdatedAt: TimeStamp(),
|
||||
ExpiresAt: time.Now().Add(expiresAfter),
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
|
||||
return m
|
||||
|
@ -81,6 +95,26 @@ func SessionStatusForbidden() *Session {
|
|||
return &Session{Status: http.StatusForbidden}
|
||||
}
|
||||
|
||||
// RegenerateID regenerated the random session ID.
|
||||
func (m *Session) RegenerateID() *Session {
|
||||
if m.ID == "" {
|
||||
// Do not delete the old session if no ID is set yet.
|
||||
} else if err := m.Delete(); err != nil {
|
||||
event.AuditErr([]string{m.IP(), "session %s", "failed to delete", "%s"}, m.RefID, err)
|
||||
} else {
|
||||
event.AuditErr([]string{m.IP(), "session %s", "deleted"}, m.RefID)
|
||||
}
|
||||
|
||||
generated := TimeStamp()
|
||||
|
||||
m.ID = rnd.SessionID()
|
||||
m.RefID = rnd.RefID(SessionPrefix)
|
||||
m.CreatedAt = generated
|
||||
m.UpdatedAt = generated
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// CacheDuration updates the session entity cache.
|
||||
func (m *Session) CacheDuration(d time.Duration) {
|
||||
if !rnd.IsSessionID(m.ID) {
|
||||
|
@ -104,13 +138,15 @@ func (m *Session) Create() (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
// Save entity properties.
|
||||
func (m *Session) Save() (err error) {
|
||||
if err = Db().Save(m).Error; err == nil && rnd.IsSessionID(m.ID) {
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Session) Save() error {
|
||||
if err := Db().Save(m).Error; err != nil {
|
||||
return err
|
||||
} else if rnd.IsSessionID(m.ID) {
|
||||
m.Cache()
|
||||
}
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a session.
|
||||
|
@ -128,7 +164,7 @@ func (m *Session) Updates(values interface{}) error {
|
|||
func (m *Session) BeforeCreate(scope *gorm.Scope) error {
|
||||
if rnd.InvalidRefID(m.RefID) {
|
||||
m.RefID = rnd.RefID(SessionPrefix)
|
||||
_ = scope.SetColumn("RefID", m.RefID)
|
||||
Log("session", "set ref id", scope.SetColumn("RefID", m.RefID))
|
||||
}
|
||||
|
||||
if rnd.IsSessionID(m.ID) {
|
||||
|
@ -147,7 +183,7 @@ func (m *Session) User() *User {
|
|||
}
|
||||
|
||||
if u := FindUserByUID(m.UserUID); u != nil {
|
||||
m.user = u
|
||||
m.SetUser(u)
|
||||
return m.user
|
||||
}
|
||||
|
||||
|
@ -164,7 +200,7 @@ func (m *Session) RefreshUser() *Session {
|
|||
|
||||
// Fetch matching record.
|
||||
if u := FindUserByUID(m.UserUID); u != nil {
|
||||
m.user = u
|
||||
m.SetUser(u)
|
||||
}
|
||||
|
||||
return m
|
||||
|
@ -237,12 +273,22 @@ func (m *Session) SetData(data *SessionData) *Session {
|
|||
|
||||
// SetContext updates the session's request context.
|
||||
func (m *Session) SetContext(c *gin.Context) *Session {
|
||||
if c == nil {
|
||||
if c == nil || m == nil {
|
||||
return m
|
||||
}
|
||||
|
||||
m.SetClientIP(c.ClientIP())
|
||||
m.SetUserAgent(c.GetHeader("User-Agent"))
|
||||
// Set client ip address.
|
||||
if ip := c.ClientIP(); ip != "" {
|
||||
m.SetClientIP(ip)
|
||||
} else if m.ClientIP == "" {
|
||||
// Unit tests often do not set a client IP.
|
||||
m.SetClientIP(UnknownIP)
|
||||
}
|
||||
|
||||
// Set client user agent.
|
||||
if ua := c.GetHeader("User-Agent"); ua != "" {
|
||||
m.SetUserAgent(ua)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
@ -254,36 +300,79 @@ func (m *Session) IsVisitor() bool {
|
|||
|
||||
// IsRegistered checks if the session belongs to a registered user account.
|
||||
func (m *Session) IsRegistered() bool {
|
||||
if m == nil || m.user == nil || rnd.InvalidUID(m.UserUID, UserUID) {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.User().IsRegistered()
|
||||
}
|
||||
|
||||
// Unregistered checks if the session belongs to a unregistered user.
|
||||
func (m *Session) Unregistered() bool {
|
||||
return !m.User().IsRegistered()
|
||||
// NotRegistered checks if the user is not registered with an own account.
|
||||
func (m *Session) NotRegistered() bool {
|
||||
return !m.IsRegistered()
|
||||
}
|
||||
|
||||
// NoShares checks if the session has no shares yet.
|
||||
func (m *Session) NoShares() bool {
|
||||
return m.Data().NoShares()
|
||||
return !m.HasShares()
|
||||
}
|
||||
|
||||
// HasShares checks if the session has any shares.
|
||||
func (m *Session) HasShares() bool {
|
||||
return m.Data().HasShares()
|
||||
if user := m.User(); user.IsRegistered() {
|
||||
return user.HasShares()
|
||||
} else if data := m.Data(); data == nil {
|
||||
return false
|
||||
} else {
|
||||
return data.HasShares()
|
||||
}
|
||||
}
|
||||
|
||||
// HasShare if the session includes the specified share
|
||||
func (m *Session) HasShare(uid string) bool {
|
||||
return m.Data().HasShare(uid)
|
||||
if user := m.User(); user.IsRegistered() {
|
||||
return user.HasShare(uid)
|
||||
} else if data := m.Data(); data == nil {
|
||||
return false
|
||||
} else {
|
||||
return data.HasShare(uid)
|
||||
}
|
||||
}
|
||||
|
||||
// SharedUIDs returns shared entity UIDs.
|
||||
func (m *Session) SharedUIDs() UIDs {
|
||||
if user := m.User(); user.IsRegistered() {
|
||||
return user.SharedUIDs()
|
||||
} else if data := m.Data(); data == nil {
|
||||
return UIDs{}
|
||||
} else {
|
||||
return data.SharedUIDs()
|
||||
}
|
||||
}
|
||||
|
||||
// RedeemToken updates shared entity UIDs using the specified token.
|
||||
func (m *Session) RedeemToken(token string) (n int) {
|
||||
if user := m.User(); user.IsRegistered() {
|
||||
return user.RedeemToken(token)
|
||||
} else if data := m.Data(); data == nil {
|
||||
return 0
|
||||
} else {
|
||||
return data.RedeemToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
// ExpiresAt returns the time when the session expires.
|
||||
func (m *Session) ExpiresAt() time.Time {
|
||||
return m.CreatedAt.Add(m.MaxAge)
|
||||
}
|
||||
|
||||
// Expired checks if the session has expired.
|
||||
func (m *Session) Expired() bool {
|
||||
if m.ExpiresAt.IsZero() {
|
||||
if m.MaxAge <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.ExpiresAt.Before(time.Now())
|
||||
return m.ExpiresAt().Before(UTC())
|
||||
}
|
||||
|
||||
// Invalid checks if the session does not belong to a registered user or a visitor with shares.
|
||||
|
@ -313,20 +402,9 @@ func (m *Session) Abort(c *gin.Context) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// SharedUIDs returns shared entity UIDs.
|
||||
func (m *Session) SharedUIDs() UIDs {
|
||||
data := m.Data()
|
||||
|
||||
if data == nil {
|
||||
return UIDs{}
|
||||
}
|
||||
|
||||
return data.SharedUIDs()
|
||||
}
|
||||
|
||||
// SetUserAgent sets the client user agent.
|
||||
func (m *Session) SetUserAgent(ua string) {
|
||||
if ua == "" {
|
||||
if m == nil || ua == "" {
|
||||
return
|
||||
} else if ua = txt.Clip(ua, 512); ua == "" {
|
||||
return
|
||||
|
@ -335,11 +413,13 @@ func (m *Session) SetUserAgent(ua string) {
|
|||
}
|
||||
|
||||
m.UserAgent = ua
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// SetClientIP sets the client IP address.
|
||||
func (m *Session) SetClientIP(ip string) {
|
||||
if ip == "" {
|
||||
if m == nil || ip == "" {
|
||||
return
|
||||
} else if parsed := net.ParseIP(ip); parsed == nil {
|
||||
return
|
||||
|
@ -355,6 +435,8 @@ func (m *Session) SetClientIP(ip string) {
|
|||
m.LoginIP = ip
|
||||
m.LoginAt = TimeStamp()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IP returns the client IP address, or "unknown" if it is unknown.
|
||||
|
@ -362,6 +444,17 @@ func (m *Session) IP() string {
|
|||
if m.ClientIP != "" {
|
||||
return m.ClientIP
|
||||
} else {
|
||||
return "unknown"
|
||||
return "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
// HttpStatus returns the session status as HTTP code for the client.
|
||||
func (m *Session) HttpStatus() int {
|
||||
if m.Status > 0 {
|
||||
return m.Status
|
||||
} else if m.Valid() {
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ func (data *SessionData) RedeemToken(token string) (n int) {
|
|||
|
||||
// No valid links found?
|
||||
if n = len(links); n == 0 {
|
||||
return 0
|
||||
return n
|
||||
}
|
||||
|
||||
// Append new token.
|
||||
|
|
|
@ -22,28 +22,33 @@ func (m SessionMap) Pointer(name string) *Session {
|
|||
|
||||
var SessionFixtures = SessionMap{
|
||||
"alice": {
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0",
|
||||
user: UserFixtures.Pointer("alice"),
|
||||
UserUID: UserFixtures.Pointer("alice").UserUID,
|
||||
UserName: UserFixtures.Pointer("alice").UserName,
|
||||
ExpiresAt: TimeStamp().Add(time.Hour * 168),
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0",
|
||||
Timeout: time.Hour * 24 * 3,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
user: UserFixtures.Pointer("alice"),
|
||||
UserUID: UserFixtures.Pointer("alice").UserUID,
|
||||
UserName: UserFixtures.Pointer("alice").UserName,
|
||||
},
|
||||
"bob": {
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1",
|
||||
user: UserFixtures.Pointer("bob"),
|
||||
UserUID: UserFixtures.Pointer("bob").UserUID,
|
||||
UserName: UserFixtures.Pointer("bob").UserName,
|
||||
ExpiresAt: TimeStamp().Add(time.Hour * 168),
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac1",
|
||||
Timeout: time.Hour * 24 * 3,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
user: UserFixtures.Pointer("bob"),
|
||||
UserUID: UserFixtures.Pointer("bob").UserUID,
|
||||
UserName: UserFixtures.Pointer("bob").UserName,
|
||||
},
|
||||
"unauthorized": {
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2",
|
||||
user: UserFixtures.Pointer("unauthorized"),
|
||||
UserUID: UserFixtures.Pointer("unauthorized").UserUID,
|
||||
UserName: UserFixtures.Pointer("unauthorized").UserName,
|
||||
ExpiresAt: TimeStamp().Add(time.Hour * 168),
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac2",
|
||||
Timeout: time.Hour * 24 * 3,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
user: UserFixtures.Pointer("unauthorized"),
|
||||
UserUID: UserFixtures.Pointer("unauthorized").UserUID,
|
||||
UserName: UserFixtures.Pointer("unauthorized").UserName,
|
||||
},
|
||||
"visitor": {
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac3",
|
||||
Timeout: time.Hour * 24 * 3,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
user: &Visitor,
|
||||
UserUID: Visitor.UserUID,
|
||||
UserName: Visitor.UserName,
|
||||
|
@ -52,14 +57,14 @@ var SessionFixtures = SessionMap{
|
|||
Tokens: []string{"1jxf3jfn2k"},
|
||||
Shares: UIDs{"at9lxuqxpogaaba8"},
|
||||
},
|
||||
ExpiresAt: TimeStamp().Add(time.Hour * 168),
|
||||
},
|
||||
"friend": {
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4",
|
||||
user: UserFixtures.Pointer("friend"),
|
||||
UserUID: UserFixtures.Pointer("friend").UserUID,
|
||||
UserName: UserFixtures.Pointer("friend").UserName,
|
||||
ExpiresAt: TimeStamp().Add(time.Hour * 168),
|
||||
ID: "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac4",
|
||||
Timeout: time.Hour * 24 * 3,
|
||||
MaxAge: time.Hour * 24 * 7,
|
||||
user: UserFixtures.Pointer("friend"),
|
||||
UserUID: UserFixtures.Pointer("friend").UserUID,
|
||||
UserName: UserFixtures.Pointer("friend").UserName,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
106
internal/entity/auth_session_signin.go
Normal file
106
internal/entity/auth_session_signin.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
// SignIn checks user authentication based on the login form.
|
||||
func (m *Session) SignIn(f form.Login, c *gin.Context) (err error) {
|
||||
if c != nil {
|
||||
m.SetContext(c)
|
||||
}
|
||||
|
||||
// Username and password provided?
|
||||
if f.HasCredentials() {
|
||||
if m.IsRegistered() {
|
||||
m.RegenerateID()
|
||||
}
|
||||
|
||||
name := f.Name()
|
||||
user := FindUserByName(name)
|
||||
|
||||
// User found?
|
||||
if user == nil {
|
||||
message := "account not found"
|
||||
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
|
||||
event.LoginError(m.IP(), name, m.UserAgent, message)
|
||||
m.Status = http.StatusUnauthorized
|
||||
return i18n.Error(i18n.ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
// Login allowed?
|
||||
if !user.LoginAllowed() {
|
||||
message := "account disabled"
|
||||
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
|
||||
event.LoginError(m.IP(), name, m.UserAgent, message)
|
||||
m.Status = http.StatusUnauthorized
|
||||
return i18n.Error(i18n.ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
// Password valid?
|
||||
if user.InvalidPassword(f.Password) {
|
||||
message := "incorrect password"
|
||||
event.AuditErr([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
|
||||
event.LoginError(m.IP(), name, m.UserAgent, message)
|
||||
m.Status = http.StatusUnauthorized
|
||||
return i18n.Error(i18n.ErrInvalidCredentials)
|
||||
} else {
|
||||
event.AuditInfo([]string{m.IP(), "session %s", "login as %s", "succeeded"}, m.RefID, clean.LogQuote(name))
|
||||
event.LoginSuccess(m.IP(), name, m.UserAgent)
|
||||
}
|
||||
|
||||
m.SetUser(user)
|
||||
}
|
||||
|
||||
// Share token provided?
|
||||
if f.HasToken() {
|
||||
user := m.User()
|
||||
|
||||
// Redeem token.
|
||||
if user.IsRegistered() {
|
||||
if shares := user.RedeemToken(f.AuthToken); shares == 0 {
|
||||
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)
|
||||
} else {
|
||||
event.AuditInfo([]string{m.IP(), "session %s", "token redeemed for %d shares"}, m.RefID, user.RedeemToken(f.AuthToken))
|
||||
}
|
||||
} else if data := m.Data(); data == nil {
|
||||
m.Status = http.StatusInternalServerError
|
||||
return i18n.Error(i18n.ErrUnexpected)
|
||||
} else if shares := data.RedeemToken(f.AuthToken); shares == 0 {
|
||||
event.AuditWarn([]string{m.IP(), "session %s", "share token %s is invalid"}, m.RefID, clean.LogQuote(f.AuthToken))
|
||||
event.LoginError(m.IP(), "", m.UserAgent, "invalid share token")
|
||||
m.Status = http.StatusNotFound
|
||||
return i18n.Error(i18n.ErrInvalidLink)
|
||||
} else {
|
||||
m.SetData(data)
|
||||
event.AuditInfo([]string{m.IP(), "session %s", "token redeemed for %d shares"}, m.RefID, shares, data)
|
||||
}
|
||||
|
||||
// Upgrade session to visitor.
|
||||
if user.IsUnknown() {
|
||||
user = &Visitor
|
||||
event.AuditDebug([]string{m.IP(), "session %s", "role upgraded to %s"}, m.RefID, user.AclRole().String())
|
||||
}
|
||||
|
||||
m.SetUser(user)
|
||||
}
|
||||
|
||||
// Unregistered visitors must use a valid share link to obtain a session.
|
||||
if m.User().NotRegistered() && m.Data().NoShares() {
|
||||
m.Status = http.StatusUnauthorized
|
||||
return i18n.Error(i18n.ErrInvalidCredentials)
|
||||
}
|
||||
|
||||
m.Status = http.StatusOK
|
||||
|
||||
return nil
|
||||
}
|
|
@ -11,24 +11,24 @@ import (
|
|||
|
||||
func TestNewSession(t *testing.T) {
|
||||
t.Run("NoSessionData", func(t *testing.T) {
|
||||
m := NewSession(time.Hour)
|
||||
m := NewSession(time.Hour*24, time.Hour*6)
|
||||
|
||||
assert.True(t, rnd.IsSessionID(m.ID))
|
||||
assert.False(t, m.CreatedAt.IsZero())
|
||||
assert.False(t, m.UpdatedAt.IsZero())
|
||||
assert.False(t, m.ExpiresAt.IsZero())
|
||||
assert.False(t, m.ExpiresAt().IsZero())
|
||||
assert.NotEmpty(t, m.ID)
|
||||
assert.NotNil(t, m.Data())
|
||||
assert.Equal(t, 0, len(m.Data().Tokens))
|
||||
})
|
||||
t.Run("EmptySessionData", func(t *testing.T) {
|
||||
m := NewSession(time.Hour)
|
||||
m := NewSession(time.Hour*24, time.Hour*6)
|
||||
m.SetData(NewSessionData())
|
||||
|
||||
assert.True(t, rnd.IsSessionID(m.ID))
|
||||
assert.False(t, m.CreatedAt.IsZero())
|
||||
assert.False(t, m.UpdatedAt.IsZero())
|
||||
assert.False(t, m.ExpiresAt.IsZero())
|
||||
assert.False(t, m.ExpiresAt().IsZero())
|
||||
assert.NotEmpty(t, m.ID)
|
||||
assert.NotNil(t, m.Data())
|
||||
assert.Equal(t, 0, len(m.Data().Tokens))
|
||||
|
@ -36,13 +36,13 @@ func TestNewSession(t *testing.T) {
|
|||
t.Run("WithSessionData", func(t *testing.T) {
|
||||
data := NewSessionData()
|
||||
data.Tokens = []string{"foo", "bar"}
|
||||
m := NewSession(time.Hour)
|
||||
m := NewSession(time.Hour*24, time.Hour*6)
|
||||
m.SetData(data)
|
||||
|
||||
assert.True(t, rnd.IsSessionID(m.ID))
|
||||
assert.False(t, m.CreatedAt.IsZero())
|
||||
assert.False(t, m.UpdatedAt.IsZero())
|
||||
assert.False(t, m.ExpiresAt.IsZero())
|
||||
assert.False(t, m.ExpiresAt().IsZero())
|
||||
assert.NotEmpty(t, m.ID)
|
||||
assert.NotNil(t, m.Data())
|
||||
assert.Len(t, m.Data().Tokens, 2)
|
||||
|
@ -55,7 +55,7 @@ func TestNewSession(t *testing.T) {
|
|||
|
||||
func TestSession_SetData(t *testing.T) {
|
||||
t.Run("Nil", func(t *testing.T) {
|
||||
m := NewSession(time.Hour)
|
||||
m := NewSession(time.Hour*24, time.Hour*6)
|
||||
|
||||
assert.NotNil(t, m)
|
||||
|
||||
|
|
170
internal/entity/auth_share.go
Normal file
170
internal/entity/auth_share.go
Normal file
|
@ -0,0 +1,170 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
const PermDefault uint = 0
|
||||
|
||||
const (
|
||||
PermNone uint = 1 << iota
|
||||
PermView
|
||||
PermReact
|
||||
PermComment
|
||||
PermUpload
|
||||
PermEdit
|
||||
PermShare
|
||||
PermAll
|
||||
)
|
||||
|
||||
// SharePrefix for RefID.
|
||||
const (
|
||||
SharePrefix = "share"
|
||||
)
|
||||
|
||||
// Shares represents shared content.
|
||||
type Shares []Share
|
||||
|
||||
// UIDs returns shared UIDs.
|
||||
func (m Shares) UIDs() UIDs {
|
||||
result := make(UIDs, len(m))
|
||||
|
||||
for i, share := range m {
|
||||
result[i] = share.ShareUID
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Empty checks if there are no shares.
|
||||
func (m Shares) Empty() bool {
|
||||
return m == nil || len(m) == 0
|
||||
}
|
||||
|
||||
// Contains checks the uid is shared.
|
||||
func (m Shares) Contains(uid string) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, share := range m {
|
||||
if share.ShareUID == uid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Share represents content shared with a user.
|
||||
type Share struct {
|
||||
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"UserUID"`
|
||||
ShareUID string `gorm:"type:VARBINARY(42);primary_key;index;" json:"ShareUID" yaml:"ShareUID"`
|
||||
LinkUID string `gorm:"type:VARBINARY(42);" json:"LinkUID,omitempty" yaml:"LinkUID,omitempty"`
|
||||
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
|
||||
Comment string `gorm:"size:512;" json:"Comment,omitempty" yaml:"Comment,omitempty"`
|
||||
Perm uint `json:"Perm,omitempty" yaml:"Perm,omitempty"`
|
||||
RefID string `gorm:"type:VARBINARY(16);" json:"-" yaml:"-"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the entity table name.
|
||||
func (Share) TableName() string {
|
||||
return "auth_shares_dev"
|
||||
}
|
||||
|
||||
// NewShare creates a new entity model.
|
||||
func NewShare(userUID, shareUid string, perm uint, expires *time.Time) *Share {
|
||||
result := &Share{
|
||||
UserUID: userUID,
|
||||
ShareUID: shareUid,
|
||||
Perm: perm,
|
||||
RefID: rnd.RefID(SharePrefix),
|
||||
CreatedAt: TimeStamp(),
|
||||
UpdatedAt: TimeStamp(),
|
||||
ExpiresAt: expires,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FindShare fetches the matching record or returns null if it was not found.
|
||||
func FindShare(find Share) *Share {
|
||||
if !find.HasID() {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := &Share{}
|
||||
|
||||
// Find matching record.
|
||||
if UnscopedDb().First(m, "user_uid = ? AND share_uid = ?", find.UserUID, find.ShareUID).RecordNotFound() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// FindShares finds all shares to which the user has access.
|
||||
func FindShares(userUid string) Shares {
|
||||
found := Shares{}
|
||||
|
||||
if rnd.InvalidUID(userUid, UserUID) {
|
||||
return found
|
||||
}
|
||||
|
||||
// Find matching record.
|
||||
if err := UnscopedDb().Find(&found, "user_uid = ? AND (expires_at IS NULL OR expires_at > ?)", userUid, TimeStamp()).Error; err != nil {
|
||||
event.AuditWarn([]string{"user %s", "find shares", "%s"}, clean.Log(userUid), err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// HasID tests if the entity has a valid uid.
|
||||
func (m *Share) HasID() bool {
|
||||
return rnd.IsUID(m.UserUID, UserUID) && rnd.IsUID(m.ShareUID, 0)
|
||||
}
|
||||
|
||||
// Create inserts a new record into the database.
|
||||
func (m *Share) Create() error {
|
||||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Share) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
||||
// Updates changes multiple record values.
|
||||
func (m *Share) Updates(values interface{}) error {
|
||||
return UnscopedDb().Model(m).Updates(values).Error
|
||||
}
|
||||
|
||||
// UpdateLink updates the share data using the Link provided.
|
||||
func (m *Share) UpdateLink(link Link) error {
|
||||
if m.ShareUID != link.ShareUID {
|
||||
return fmt.Errorf("shared uid does not match")
|
||||
}
|
||||
|
||||
m.LinkUID = link.LinkUID
|
||||
m.Comment = link.Comment
|
||||
m.Perm = link.Perm
|
||||
m.UpdatedAt = TimeStamp()
|
||||
m.ExpiresAt = link.ExpiresAt()
|
||||
|
||||
values := Values{
|
||||
"link_uid": m.LinkUID,
|
||||
"expires_at": m.ExpiresAt,
|
||||
"comment": m.Comment,
|
||||
"perm": m.Perm,
|
||||
"updated_at": m.UpdatedAt}
|
||||
|
||||
return m.Updates(values)
|
||||
}
|
46
internal/entity/auth_share_fixtures.go
Normal file
46
internal/entity/auth_share_fixtures.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
type ShareMap map[string]Share
|
||||
|
||||
// Get returns a fixture for use in tests.
|
||||
func (m ShareMap) Get(name string) Share {
|
||||
if result, ok := m[name]; ok {
|
||||
return result
|
||||
}
|
||||
|
||||
return Share{}
|
||||
}
|
||||
|
||||
// Pointer returns a fixture pointer for use in tests.
|
||||
func (m ShareMap) Pointer(name string) *Share {
|
||||
if result, ok := m[name]; ok {
|
||||
return &result
|
||||
}
|
||||
|
||||
return &Share{}
|
||||
}
|
||||
|
||||
// ShareFixtures specifies fixtures for use in tests.
|
||||
var ShareFixtures = ShareMap{
|
||||
"AliceAlbum": {
|
||||
UserUID: "uqxetse3cy5eo9z2",
|
||||
ShareUID: "at9lxuqxpogaaba9",
|
||||
ExpiresAt: nil,
|
||||
Comment: "The quick brown fox jumps over the lazy dog.",
|
||||
Perm: PermShare,
|
||||
RefID: rnd.RefID(SharePrefix),
|
||||
CreatedAt: TimeStamp(),
|
||||
UpdatedAt: TimeStamp(),
|
||||
},
|
||||
}
|
||||
|
||||
// CreateShareFixtures creates the fixtures specified above.
|
||||
func CreateShareFixtures() {
|
||||
for _, entity := range ShareFixtures {
|
||||
Db().Create(&entity)
|
||||
}
|
||||
}
|
77
internal/entity/auth_share_test.go
Normal file
77
internal/entity/auth_share_test.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
package entity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
func TestNewShare(t *testing.T) {
|
||||
expires := TimeStamp().Add(time.Hour * 48)
|
||||
m := NewShare(Admin.UID(), AlbumFixtures.Get("berlin-2019").AlbumUID, PermReact, &expires)
|
||||
|
||||
assert.True(t, m.HasID())
|
||||
assert.True(t, rnd.IsRefID(m.RefID))
|
||||
assert.True(t, rnd.IsUID(m.UserUID, UserUID))
|
||||
assert.True(t, rnd.IsUID(m.ShareUID, AlbumUID))
|
||||
assert.Equal(t, PermReact, m.Perm)
|
||||
assert.Equal(t, expires, *m.ExpiresAt)
|
||||
assert.Equal(t, "", m.Comment)
|
||||
assert.Equal(t, "", m.LinkUID)
|
||||
}
|
||||
|
||||
func TestPerm(t *testing.T) {
|
||||
assert.Equal(t, uint(0), PermDefault)
|
||||
assert.Equal(t, uint(1), PermNone)
|
||||
assert.Equal(t, uint(2), PermView)
|
||||
assert.Equal(t, uint(4), PermReact)
|
||||
assert.Equal(t, uint(8), PermComment)
|
||||
assert.Equal(t, uint(16), PermUpload)
|
||||
assert.Equal(t, uint(32), PermEdit)
|
||||
assert.Equal(t, uint(64), PermShare)
|
||||
assert.Equal(t, uint(128), PermAll)
|
||||
}
|
||||
|
||||
func TestFindShare(t *testing.T) {
|
||||
t.Run("AliceAlbum", func(t *testing.T) {
|
||||
m := FindShare(Share{UserUID: "uqxetse3cy5eo9z2", ShareUID: "at9lxuqxpogaaba9"})
|
||||
|
||||
expected := ShareFixtures.Get("AliceAlbum")
|
||||
|
||||
assert.NotNil(t, m)
|
||||
assert.True(t, m.HasID())
|
||||
assert.True(t, rnd.IsRefID(m.RefID))
|
||||
assert.True(t, rnd.IsUID(m.UserUID, UserUID))
|
||||
assert.True(t, rnd.IsUID(m.ShareUID, AlbumUID))
|
||||
assert.Equal(t, expected.Perm, m.Perm)
|
||||
assert.Equal(t, expected.ExpiresAt, m.ExpiresAt)
|
||||
assert.Equal(t, expected.Comment, m.Comment)
|
||||
assert.Equal(t, expected.LinkUID, m.LinkUID)
|
||||
assert.Equal(t, expected.UserUID, m.UserUID)
|
||||
assert.Equal(t, expected.ShareUID, m.ShareUID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindShares(t *testing.T) {
|
||||
found := FindShares(UserFixtures.Pointer("alice").UID())
|
||||
assert.NotNil(t, found)
|
||||
assert.Len(t, found, 1)
|
||||
|
||||
m := found[0]
|
||||
expected := ShareFixtures.Get("AliceAlbum")
|
||||
|
||||
assert.NotNil(t, m)
|
||||
assert.True(t, m.HasID())
|
||||
assert.True(t, rnd.IsRefID(m.RefID))
|
||||
assert.True(t, rnd.IsUID(m.UserUID, UserUID))
|
||||
assert.True(t, rnd.IsUID(m.ShareUID, AlbumUID))
|
||||
assert.Equal(t, expected.Perm, m.Perm)
|
||||
assert.Equal(t, expected.ExpiresAt, m.ExpiresAt)
|
||||
assert.Equal(t, expected.Comment, m.Comment)
|
||||
assert.Equal(t, expected.LinkUID, m.LinkUID)
|
||||
assert.Equal(t, expected.UserUID, m.UserUID)
|
||||
assert.Equal(t, expected.ShareUID, m.ShareUID)
|
||||
}
|
|
@ -37,7 +37,7 @@ type Users []User
|
|||
type User struct {
|
||||
ID int `gorm:"primary_key" json:"-" yaml:"-"`
|
||||
UUID string `gorm:"type:VARBINARY(64);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"`
|
||||
UserUID string `gorm:"type:VARBINARY(64);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);column:user_uid;unique_index;" json:"UID" yaml:"UID"`
|
||||
AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"`
|
||||
AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"`
|
||||
UserName string `gorm:"size:64;index;" json:"Name" yaml:"Name,omitempty"`
|
||||
|
@ -49,6 +49,7 @@ type User struct {
|
|||
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
|
||||
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
|
||||
LoginAt *time.Time `json:"LoginAt,omitempty" yaml:"LoginAt,omitempty"`
|
||||
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
|
||||
BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
|
||||
UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
|
||||
CanSync bool `json:"CanSync,omitempty" yaml:"CanSync,omitempty"`
|
||||
|
@ -69,8 +70,8 @@ type User struct {
|
|||
RefID string `gorm:"type:VARBINARY(16);" json:"-" yaml:"-"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
|
||||
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
||||
Shares Shares `gorm:"-" json:"Shares,omitempty" yaml:"Shares,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the entity table name.
|
||||
|
@ -124,7 +125,7 @@ func FindUserByName(name string) *User {
|
|||
|
||||
// FindUserByUID returns an existing user or nil if not found.
|
||||
func FindUserByUID(uid string) *User {
|
||||
if uid == "" {
|
||||
if rnd.InvalidUID(uid, UserUID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -132,7 +133,6 @@ func FindUserByUID(uid string) *User {
|
|||
|
||||
// Find matching record.
|
||||
if UnscopedDb().First(m, "user_uid = ?", uid).RecordNotFound() {
|
||||
event.AuditWarn([]string{"user", "failed to find uid %s"}, clean.Log(uid))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -142,9 +142,24 @@ func FindUserByUID(uid string) *User {
|
|||
|
||||
// UID returns the unique id as string.
|
||||
func (m *User) UID() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return m.UserUID
|
||||
}
|
||||
|
||||
// SameUID checks if the given uid matches the own uid.
|
||||
func (m *User) SameUID(uid string) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
} else if m.UserUID == "" || rnd.InvalidUID(uid, UserUID) {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.UserUID == uid
|
||||
}
|
||||
|
||||
// InitAccount sets the name and password of the initial admin account.
|
||||
func (m *User) InitAccount(login, password string) (updated bool) {
|
||||
if !m.IsRegistered() {
|
||||
|
@ -167,13 +182,13 @@ func (m *User) InitAccount(login, password string) (updated bool) {
|
|||
|
||||
// Save password.
|
||||
if err := pw.Save(); err != nil {
|
||||
event.AuditErr([]string{"user", "failed to change password for %s", "%s"}, clean.LogQuote(login), err)
|
||||
event.AuditErr([]string{"user %s", "failed to change password", "%s"}, m.RefID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Change username.
|
||||
if err := m.UpdateName(login); err != nil {
|
||||
event.AuditErr([]string{"user", m.UserUID, "failed to change name to %s", "%s"}, clean.LogQuote(login), err)
|
||||
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(login), err)
|
||||
}
|
||||
|
||||
return true
|
||||
|
@ -190,7 +205,7 @@ func (m *User) Create() (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
// Save entity properties.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *User) Save() (err error) {
|
||||
err = Db().Save(m).Error
|
||||
|
||||
|
@ -230,10 +245,10 @@ func (m *User) LoadRelated() *User {
|
|||
// SaveRelated saves related settings and details.
|
||||
func (m *User) SaveRelated() *User {
|
||||
if err := m.Settings().Save(); err != nil {
|
||||
event.AuditErr([]string{"user", m.UserUID, "failed to save settings", "%s"}, err)
|
||||
event.AuditErr([]string{"user %s", "failed to save settings", "%s"}, m.RefID, err)
|
||||
}
|
||||
if err := m.Details().Save(); err != nil {
|
||||
event.AuditErr([]string{"user", m.UserUID, "failed to save details", "%s"}, err)
|
||||
event.AuditErr([]string{"user %s", "failed to save details", "%s"}, m.RefID, err)
|
||||
}
|
||||
|
||||
return m
|
||||
|
@ -256,7 +271,7 @@ func (m *User) BeforeCreate(scope *gorm.Scope) error {
|
|||
|
||||
if rnd.InvalidRefID(m.RefID) {
|
||||
m.RefID = rnd.RefID(UserPrefix)
|
||||
_ = scope.SetColumn("RefID", m.RefID)
|
||||
Log("user", "set ref id", scope.SetColumn("RefID", m.RefID))
|
||||
}
|
||||
|
||||
if rnd.IsUnique(m.UserUID, UserUID) {
|
||||
|
@ -445,11 +460,24 @@ func (m *User) Attr() string {
|
|||
|
||||
// IsRegistered checks if the user is registered e.g. has a username.
|
||||
func (m *User) IsRegistered() bool {
|
||||
return m.UserName != "" && rnd.IsUID(m.UserUID, UserUID)
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.UserName != "" && rnd.IsUID(m.UserUID, UserUID) && !m.IsVisitor()
|
||||
}
|
||||
|
||||
// NotRegistered checks if the user is not registered with an own account.
|
||||
func (m *User) NotRegistered() bool {
|
||||
return !m.IsRegistered()
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user is an admin with username.
|
||||
func (m *User) IsAdmin() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.IsRegistered() && (m.SuperAdmin || m.AclRole() == acl.RoleAdmin)
|
||||
}
|
||||
|
||||
|
@ -474,14 +502,14 @@ func (m *User) DeleteSessions(omit []string) (deleted int) {
|
|||
sess := Sessions{}
|
||||
|
||||
if err := stmt.Find(&sess).Error; err != nil {
|
||||
event.AuditErr([]string{"user", "failed to invalidate sessions", "%s"}, m.UserUID, err)
|
||||
event.AuditErr([]string{"user %s", "failed to invalidate sessions", "%s"}, m.RefID, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
// This will also remove the session from the cache.
|
||||
for _, s := range sess {
|
||||
if err := s.Delete(); err != nil {
|
||||
event.AuditWarn([]string{"user", "failed to invalidate session %s"}, m.UserUID, clean.Log(s.RefID))
|
||||
event.AuditWarn([]string{"user %s", "failed to invalidate session %s", "%s"}, m.RefID, clean.Log(s.RefID), err)
|
||||
} else {
|
||||
deleted++
|
||||
}
|
||||
|
@ -604,3 +632,75 @@ func (m *User) SetFormValues(frm form.User) *User {
|
|||
|
||||
return m
|
||||
}
|
||||
|
||||
// RefreshShares updates the list of shares.
|
||||
func (m *User) RefreshShares() *User {
|
||||
m.Shares = FindShares(m.UID())
|
||||
return m
|
||||
}
|
||||
|
||||
// NoShares checks if the user has no shares yet.
|
||||
func (m *User) NoShares() bool {
|
||||
if !m.IsRegistered() {
|
||||
return true
|
||||
}
|
||||
|
||||
return m.Shares.Empty()
|
||||
}
|
||||
|
||||
// HasShares checks if the user has any shares.
|
||||
func (m *User) HasShares() bool {
|
||||
return !m.NoShares()
|
||||
}
|
||||
|
||||
// HasShare if a uid was shared with the user.
|
||||
func (m *User) HasShare(uid string) bool {
|
||||
if !m.IsRegistered() || m.NoShares() {
|
||||
return false
|
||||
}
|
||||
|
||||
return m.Shares.Contains(uid)
|
||||
}
|
||||
|
||||
// SharedUIDs returns shared entity UIDs.
|
||||
func (m *User) SharedUIDs() UIDs {
|
||||
if m.IsRegistered() && m.Shares.Empty() {
|
||||
m.RefreshShares()
|
||||
}
|
||||
|
||||
return m.Shares.UIDs()
|
||||
}
|
||||
|
||||
// RedeemToken updates shared entity UIDs using the specified token.
|
||||
func (m *User) RedeemToken(token string) (n int) {
|
||||
if !m.IsRegistered() {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find links.
|
||||
links := FindValidLinks(token, "")
|
||||
|
||||
// Found?
|
||||
if n = len(links); n == 0 {
|
||||
return n
|
||||
}
|
||||
|
||||
// Find shares.
|
||||
for _, link := range links {
|
||||
if found := FindShare(Share{UserUID: m.UID(), ShareUID: link.ShareUID}); found == nil {
|
||||
share := NewShare(m.UID(), link.ShareUID, link.Perm, link.ExpiresAt())
|
||||
share.LinkUID = link.LinkUID
|
||||
share.Comment = link.Comment
|
||||
|
||||
if err := share.Save(); err != nil {
|
||||
event.AuditErr([]string{"user %s", "token %s", "failed to redeem shares", "%s"}, m.RefID, clean.Log(token), err)
|
||||
} else {
|
||||
link.Redeem()
|
||||
}
|
||||
} else if err := found.UpdateLink(link); err != nil {
|
||||
event.AuditErr([]string{"user %s", "token %s", "failed to update shares", "%s"}, m.RefID, clean.Log(token), err)
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
|
|
@ -7,9 +7,9 @@ import (
|
|||
|
||||
// Role defaults.
|
||||
const (
|
||||
AdminUserName = "admin"
|
||||
AdminDisplayName = "Admin"
|
||||
GuestDisplayName = "Visitor"
|
||||
AdminUserName = "admin"
|
||||
AdminDisplayName = "Admin"
|
||||
VisitorDisplayName = "Visitor"
|
||||
)
|
||||
|
||||
// Admin is the default admin user.
|
||||
|
@ -45,7 +45,7 @@ var Visitor = User{
|
|||
UserUID: "u000000000000002",
|
||||
UserRole: acl.RoleVisitor.String(),
|
||||
UserName: "",
|
||||
DisplayName: GuestDisplayName,
|
||||
DisplayName: VisitorDisplayName,
|
||||
SuperAdmin: false,
|
||||
CanLogin: false,
|
||||
CanSync: false,
|
||||
|
|
|
@ -16,12 +16,12 @@ const (
|
|||
|
||||
// UserDetails represents user profile information.
|
||||
type UserDetails struct {
|
||||
UserUID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;" json:"-" yaml:"UserUID"`
|
||||
SubjUID string `gorm:"type:VARBINARY(64);index;" json:"SubjUID,omitempty" yaml:"SubjUID,omitempty"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"UserUID"`
|
||||
SubjUID string `gorm:"type:VARBINARY(42);index;" json:"SubjUID,omitempty" yaml:"SubjUID,omitempty"`
|
||||
SubjSrc string `gorm:"type:VARBINARY(8);default:'';" json:"SubjSrc,omitempty" yaml:"SubjSrc,omitempty"`
|
||||
PlaceID string `gorm:"type:VARBINARY(64);index;default:'zz'" json:"PlaceID,omitempty" yaml:"-"`
|
||||
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID,omitempty" yaml:"-"`
|
||||
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc,omitempty" yaml:"PlaceSrc,omitempty"`
|
||||
CellID string `gorm:"type:VARBINARY(64);index;default:'zz'" json:"CellID,omitempty" yaml:"CellID,omitempty"`
|
||||
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID,omitempty" yaml:"CellID,omitempty"`
|
||||
IdURL string `gorm:"type:VARBINARY(512);column:id_url;" json:"IdURL,omitempty" yaml:"IdURL,omitempty"`
|
||||
AvatarURL string `gorm:"type:VARBINARY(512);column:avatar_url" json:"AvatarURL,omitempty" yaml:"AvatarURL,omitempty"`
|
||||
SiteURL string `gorm:"type:VARBINARY(512);column:site_url" json:"SiteURL,omitempty" yaml:"SiteURL,omitempty"`
|
||||
|
@ -87,7 +87,7 @@ func (m *UserDetails) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save entity properties.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *UserDetails) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
// UserSettings represents user preferences.
|
||||
type UserSettings struct {
|
||||
UserUID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;" json:"-" yaml:"UserUID"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"-" yaml:"UserUID"`
|
||||
UITheme string `gorm:"type:VARBINARY(32);column:ui_theme;" json:"UITheme,omitempty" yaml:"UITheme,omitempty"`
|
||||
UILanguage string `gorm:"type:VARBINARY(32);column:ui_language;" json:"UILanguage,omitempty" yaml:"UILanguage,omitempty"`
|
||||
UITimeZone string `gorm:"type:VARBINARY(64);column:ui_time_zone;" json:"UITimeZone,omitempty" yaml:"UITimeZone,omitempty"`
|
||||
|
@ -65,7 +65,7 @@ func (m *UserSettings) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save entity properties.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *UserSettings) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ func TestNewUser(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestFindUserByName(t *testing.T) {
|
||||
t.Run("admin", func(t *testing.T) {
|
||||
t.Run("Admin", func(t *testing.T) {
|
||||
m := FindUserByName("admin")
|
||||
|
||||
if m == nil {
|
||||
|
@ -43,7 +43,7 @@ func TestFindUserByName(t *testing.T) {
|
|||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
|
||||
t.Run("alice", func(t *testing.T) {
|
||||
t.Run("Alice", func(t *testing.T) {
|
||||
m := FindUserByName("alice")
|
||||
|
||||
if m == nil {
|
||||
|
@ -64,7 +64,7 @@ func TestFindUserByName(t *testing.T) {
|
|||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
|
||||
t.Run("bob", func(t *testing.T) {
|
||||
t.Run("Bob", func(t *testing.T) {
|
||||
m := FindUserByName("bob")
|
||||
|
||||
if m == nil {
|
||||
|
@ -83,7 +83,7 @@ func TestFindUserByName(t *testing.T) {
|
|||
assert.NotEmpty(t, m.UpdatedAt)
|
||||
})
|
||||
|
||||
t.Run("unknown", func(t *testing.T) {
|
||||
t.Run("Unknown", func(t *testing.T) {
|
||||
m := FindUserByName("")
|
||||
|
||||
if m != nil {
|
||||
|
@ -91,7 +91,7 @@ func TestFindUserByName(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
m := FindUserByName("xxx")
|
||||
|
||||
if m != nil {
|
||||
|
@ -671,3 +671,15 @@ func TestUser_UploadAllowed(t *testing.T) {
|
|||
assert.False(t, UserFixtures.Pointer("deleted").UploadAllowed())
|
||||
assert.True(t, UserFixtures.Pointer("friend").UploadAllowed())
|
||||
}
|
||||
|
||||
func TestUser_SharedUIDs(t *testing.T) {
|
||||
t.Run("AliceAlbum", func(t *testing.T) {
|
||||
m := UserFixtures.Pointer("alice")
|
||||
assert.NotNil(t, m)
|
||||
|
||||
result := m.SharedUIDs()
|
||||
assert.NotNil(t, result)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, UIDs{"at9lxuqxpogaaba9"}, result)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -15,12 +15,12 @@ var cellMutex = sync.Mutex{}
|
|||
|
||||
// Cell represents an S2 cell with metadata and reference to a place.
|
||||
type Cell struct {
|
||||
ID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
|
||||
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"ID" yaml:"ID"`
|
||||
CellName string `gorm:"type:VARCHAR(200);" json:"Name" yaml:"Name,omitempty"`
|
||||
CellStreet string `gorm:"type:VARCHAR(100);" json:"Street" yaml:"Street,omitempty"`
|
||||
CellPostcode string `gorm:"type:VARCHAR(50);" json:"Postcode" yaml:"Postcode,omitempty"`
|
||||
CellCategory string `gorm:"type:VARCHAR(50);" json:"Category" yaml:"Category,omitempty"`
|
||||
PlaceID string `gorm:"type:VARBINARY(64);default:'zz'" json:"-" yaml:"PlaceID"`
|
||||
PlaceID string `gorm:"type:VARBINARY(42);default:'zz'" json:"-" yaml:"PlaceID"`
|
||||
Place *Place `gorm:"PRELOAD:true" json:"Place" yaml:"-"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
|
@ -210,7 +210,7 @@ func (m *Cell) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Cell) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
// TestEntity is an entity dedicated to test database management functionality.
|
||||
type TestEntity struct {
|
||||
ID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;" json:"TestID" yaml:"TestID"`
|
||||
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"TestID" yaml:"TestID"`
|
||||
TestLabel string `gorm:"type:VARCHAR(400);unique_index;" json:"Label" yaml:"Label"`
|
||||
TestCount int `gorm:"default:1" json:"Count" yaml:"-"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
|
|
|
@ -53,7 +53,7 @@ func (m *Details) Create() error {
|
|||
return UnscopedDb().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates existing photo details or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Details) Save() error {
|
||||
if m.PhotoID == 0 {
|
||||
return fmt.Errorf("details: photo id must not be empty (save)")
|
||||
|
|
|
@ -5,8 +5,7 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Save updates a record in the database, or inserts
|
||||
// if it does not exist.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func Save(m interface{}, keyNames ...string) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
|
|
@ -28,6 +28,7 @@ var Entities = Tables{
|
|||
FileShare{}.TableName(): &FileShare{},
|
||||
FileSync{}.TableName(): &FileSync{},
|
||||
Photo{}.TableName(): &Photo{},
|
||||
PhotoAuth{}.TableName(): &PhotoAuth{},
|
||||
Details{}.TableName(): &Details{},
|
||||
Place{}.TableName(): &Place{},
|
||||
Cell{}.TableName(): &Cell{},
|
||||
|
@ -35,6 +36,7 @@ var Entities = Tables{
|
|||
Lens{}.TableName(): &Lens{},
|
||||
Country{}.TableName(): &Country{},
|
||||
Album{}.TableName(): &Album{},
|
||||
AlbumAuth{}.TableName(): &AlbumAuth{},
|
||||
PhotoAlbum{}.TableName(): &PhotoAlbum{},
|
||||
Label{}.TableName(): &Label{},
|
||||
Category{}.TableName(): &Category{},
|
||||
|
@ -46,6 +48,7 @@ var Entities = Tables{
|
|||
Face{}.TableName(): &Face{},
|
||||
Marker{}.TableName(): &Marker{},
|
||||
Reaction{}.TableName(): &Reaction{},
|
||||
Share{}.TableName(): &Share{},
|
||||
}
|
||||
|
||||
// WaitForMigration waits for the database migration to be successful.
|
||||
|
|
|
@ -84,12 +84,11 @@ func TestUpdate(t *testing.T) {
|
|||
})
|
||||
t.Run("NonExistentKeys", func(t *testing.T) {
|
||||
m := PhotoFixtures.Pointer("Photo01")
|
||||
m.ID = uint(99999 + r.Intn(10000))
|
||||
m.ID = uint(10000000 + r.Intn(10000))
|
||||
m.PhotoUID = rnd.GenerateUID(PhotoUID)
|
||||
updatedAt := m.UpdatedAt
|
||||
if err := Update(m, "ID", "PhotoUID"); err == nil {
|
||||
t.Fatal("error expected")
|
||||
return
|
||||
t.Errorf("expected error: %#v", m)
|
||||
} else {
|
||||
assert.ErrorContains(t, err, "record not found")
|
||||
assert.Greater(t, m.UpdatedAt.UTC(), updatedAt.UTC())
|
||||
|
|
|
@ -21,7 +21,7 @@ type Face struct {
|
|||
FaceSrc string `gorm:"type:VARBINARY(8);" json:"Src" yaml:"Src,omitempty"`
|
||||
FaceKind int `json:"Kind" yaml:"Kind,omitempty"`
|
||||
FaceHidden bool `json:"Hidden" yaml:"Hidden,omitempty"`
|
||||
SubjUID string `gorm:"type:VARBINARY(64);index;default:'';" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
||||
SubjUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
||||
Samples int `json:"Samples" yaml:"Samples,omitempty"`
|
||||
SampleRadius float64 `json:"SampleRadius" yaml:"SampleRadius,omitempty"`
|
||||
Collisions int `json:"Collisions" yaml:"Collisions,omitempty"`
|
||||
|
@ -264,12 +264,12 @@ func (m *Face) MatchMarkers(faceIds []string) error {
|
|||
}
|
||||
|
||||
// SetSubjectUID updates the face's subject uid and related markers.
|
||||
func (m *Face) SetSubjectUID(subjUID string) (err error) {
|
||||
func (m *Face) SetSubjectUID(subjUid string) (err error) {
|
||||
// Update face.
|
||||
if err = m.Update("SubjUID", subjUID); err != nil {
|
||||
if err = m.Update("SubjUID", subjUid); err != nil {
|
||||
return err
|
||||
} else {
|
||||
m.SubjUID = subjUID
|
||||
m.SubjUID = subjUid
|
||||
}
|
||||
|
||||
// Update related markers.
|
||||
|
@ -410,13 +410,13 @@ func FindFace(id string) *Face {
|
|||
}
|
||||
|
||||
// ValidFaceCount counts the number of valid face markers for a file uid.
|
||||
func ValidFaceCount(fileUID string) (c int) {
|
||||
if !rnd.IsUID(fileUID, FileUID) {
|
||||
func ValidFaceCount(fileUid string) (c int) {
|
||||
if !rnd.IsUID(fileUid, FileUID) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := Db().Model(Marker{}).
|
||||
Where("file_uid = ? AND marker_type = ?", fileUID, MarkerFace).
|
||||
Where("file_uid = ? AND marker_type = ?", fileUid, MarkerFace).
|
||||
Where("marker_invalid = 0").
|
||||
Count(&c).Error; err != nil {
|
||||
log.Errorf("file: %s (count faces)", err)
|
||||
|
|
|
@ -41,13 +41,13 @@ type File struct {
|
|||
ID uint `gorm:"primary_key" json:"-" yaml:"-"`
|
||||
Photo *Photo `json:"-" yaml:"-"`
|
||||
PhotoID uint `gorm:"index:idx_files_photo_id;" json:"-" yaml:"-"`
|
||||
PhotoUID string `gorm:"type:VARBINARY(64);index;" json:"PhotoUID" yaml:"PhotoUID"`
|
||||
PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"`
|
||||
PhotoTakenAt time.Time `gorm:"type:DATETIME;index;" json:"TakenAt" yaml:"TakenAt"`
|
||||
TimeIndex *string `gorm:"type:VARBINARY(64);" json:"TimeIndex" yaml:"TimeIndex"`
|
||||
MediaID *string `gorm:"type:VARBINARY(32);" json:"MediaID" yaml:"MediaID"`
|
||||
MediaUTC int64 `gorm:"column:media_utc;index;" json:"MediaUTC" yaml:"MediaUTC,omitempty"`
|
||||
InstanceID string `gorm:"type:VARBINARY(64);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
|
||||
FileUID string `gorm:"type:VARBINARY(64);unique_index;" json:"UID" yaml:"UID"`
|
||||
FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
||||
FileName string `gorm:"type:VARBINARY(1024);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
|
||||
FileRoot string `gorm:"type:VARBINARY(16);default:'/';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
|
||||
OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"`
|
||||
|
@ -85,6 +85,7 @@ type File struct {
|
|||
CreatedIn int64 `json:"CreatedIn" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
UpdatedIn int64 `json:"UpdatedIn" yaml:"-"`
|
||||
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
||||
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
||||
Share []FileShare `json:"-" yaml:"-"`
|
||||
Sync []FileSync `json:"-" yaml:"-"`
|
||||
|
@ -176,10 +177,10 @@ func FirstFileByHash(fileHash string) (File, error) {
|
|||
}
|
||||
|
||||
// PrimaryFile returns the primary file for a photo uid.
|
||||
func PrimaryFile(photoUID string) (*File, error) {
|
||||
func PrimaryFile(photoUid string) (*File, error) {
|
||||
file := File{}
|
||||
|
||||
res := Db().Unscoped().First(&file, "file_primary = 1 AND photo_uid = ?", photoUID)
|
||||
res := Db().Unscoped().First(&file, "file_primary = 1 AND photo_uid = ?", photoUid)
|
||||
|
||||
return &file, res.Error
|
||||
}
|
||||
|
@ -437,7 +438,7 @@ func (m *File) ResolvePrimary() (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
// Save stores the file in the database.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *File) Save() error {
|
||||
if m.PhotoID == 0 {
|
||||
return fmt.Errorf("file %s: cannot save file with empty photo id", m.FileUID)
|
||||
|
@ -722,14 +723,14 @@ func (m *File) AddFaces(faces face.Faces) {
|
|||
}
|
||||
|
||||
// AddFace adds a face marker to the file.
|
||||
func (m *File) AddFace(f face.Face, subjUID string) {
|
||||
func (m *File) AddFace(f face.Face, subjUid string) {
|
||||
// Only add faces with exactly one embedding so that they can be compared and clustered.
|
||||
if !f.Embeddings.One() {
|
||||
return
|
||||
}
|
||||
|
||||
// Create new marker from face.
|
||||
marker := NewFaceMarker(f, *m, subjUID)
|
||||
marker := NewFaceMarker(f, *m, subjUid)
|
||||
|
||||
// Failed creating new marker?
|
||||
if marker == nil {
|
||||
|
|
|
@ -49,12 +49,12 @@ func (m *FileShare) Updates(values interface{}) error {
|
|||
return UnscopedDb().Model(m).UpdateColumns(values).Error
|
||||
}
|
||||
|
||||
// Updates a column in the database.
|
||||
// Update updates a column value in the database.
|
||||
func (m *FileShare) Update(attr string, value interface{}) error {
|
||||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *FileShare) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@ func (m *FileSync) Update(attr string, value interface{}) error {
|
|||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *FileSync) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -31,4 +31,5 @@ func CreateTestFixtures() {
|
|||
CreateSessionFixtures()
|
||||
CreateReactionFixtures()
|
||||
CreatePasswordFixtures()
|
||||
CreateShareFixtures()
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ type Folders []Folder
|
|||
type Folder struct {
|
||||
Path string `gorm:"type:VARBINARY(1024);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"`
|
||||
Root string `gorm:"type:VARBINARY(16);default:'';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root,omitempty"`
|
||||
FolderUID string `gorm:"type:VARBINARY(64);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
||||
FolderUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
||||
FolderType string `gorm:"type:VARBINARY(16);" json:"Type" yaml:"Type,omitempty"`
|
||||
FolderTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title,omitempty"`
|
||||
FolderCategory string `gorm:"type:VARCHAR(100);index;" json:"Category" yaml:"Category,omitempty"`
|
||||
|
@ -42,6 +42,7 @@ type Folder struct {
|
|||
CreatedAt time.Time `json:"-" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"-" yaml:"-"`
|
||||
ModifiedAt time.Time `json:"ModifiedAt,omitempty" yaml:"-"`
|
||||
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
||||
DeletedAt *time.Time `sql:"index" json:"-"`
|
||||
}
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ func (m *Keyword) Update(attr string, value interface{}) error {
|
|||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Keyword) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ type Labels []Label
|
|||
// Label is used for photo, album and location categorization
|
||||
type Label struct {
|
||||
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
|
||||
LabelUID string `gorm:"type:VARBINARY(64);unique_index;" json:"UID" yaml:"UID"`
|
||||
LabelUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
||||
LabelSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
|
||||
CustomSlug string `gorm:"type:VARBINARY(160);index;" json:"CustomSlug" yaml:"-"`
|
||||
LabelName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
|
||||
|
@ -40,6 +40,7 @@ type Label struct {
|
|||
ThumbSrc string `gorm:"type:VARBINARY(8);default:''" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"`
|
||||
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
||||
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
||||
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
||||
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
||||
New bool `gorm:"-" json:"-" yaml:"-"`
|
||||
}
|
||||
|
@ -80,7 +81,7 @@ func NewLabel(name string, priority int) *Label {
|
|||
return result
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new label.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Label) Save() error {
|
||||
labelMutex.Lock()
|
||||
defer labelMutex.Unlock()
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
|
@ -17,19 +19,20 @@ const (
|
|||
|
||||
type Links []Link
|
||||
|
||||
// Link represents a sharing link.
|
||||
// Link represents a link to share content.
|
||||
type Link struct {
|
||||
LinkUID string `gorm:"type:VARBINARY(64);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
||||
ShareUID string `gorm:"type:VARBINARY(64);unique_index:idx_links_uid_token;" json:"Share" yaml:"Share"`
|
||||
LinkUID string `gorm:"type:VARBINARY(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
|
||||
ShareUID string `gorm:"type:VARBINARY(42);unique_index:idx_links_uid_token;" json:"ShareUID" yaml:"ShareUID"`
|
||||
ShareSlug string `gorm:"type:VARBINARY(160);index;" json:"Slug" yaml:"Slug,omitempty"`
|
||||
LinkToken string `gorm:"type:VARBINARY(160);unique_index:idx_links_uid_token;" json:"Token" yaml:"Token,omitempty"`
|
||||
LinkExpires int `json:"Expires" yaml:"Expires,omitempty"`
|
||||
LinkViews uint `json:"Views" yaml:"-"`
|
||||
MaxViews uint `json:"MaxViews" yaml:"-"`
|
||||
HasPassword bool `json:"HasPassword" yaml:"HasPassword,omitempty"`
|
||||
CanComment bool `json:"CanComment" yaml:"CanComment,omitempty"`
|
||||
CanEdit bool `json:"CanEdit" yaml:"CanEdit,omitempty"`
|
||||
OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"`
|
||||
Comment string `gorm:"size:512;" json:"Comment,omitempty" yaml:"Comment,omitempty"`
|
||||
Perm uint `json:"Perm,omitempty" yaml:"Perm,omitempty"`
|
||||
RefID string `gorm:"type:VARBINARY(16);" json:"-" yaml:"-"`
|
||||
CreatedBy string `gorm:"type:VARBINARY(42);index" json:"CreatedBy,omitempty" yaml:"CreatedBy,omitempty"`
|
||||
CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt" yaml:"CreatedAt"`
|
||||
ModifiedAt time.Time `deepcopier:"skip" json:"ModifiedAt" yaml:"ModifiedAt"`
|
||||
}
|
||||
|
@ -41,6 +44,11 @@ func (Link) TableName() string {
|
|||
|
||||
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
||||
func (m *Link) BeforeCreate(scope *gorm.Scope) error {
|
||||
if rnd.InvalidRefID(m.RefID) {
|
||||
m.RefID = rnd.RefID(SessionPrefix)
|
||||
Log("link", "set ref id", scope.SetColumn("RefID", m.RefID))
|
||||
}
|
||||
|
||||
if rnd.IsUnique(m.LinkUID, LinkUID) {
|
||||
return nil
|
||||
}
|
||||
|
@ -49,21 +57,19 @@ func (m *Link) BeforeCreate(scope *gorm.Scope) error {
|
|||
}
|
||||
|
||||
// NewLink creates a sharing link.
|
||||
func NewLink(shareUID string, canComment, canEdit bool) Link {
|
||||
return NewUserLink(shareUID, canComment, canEdit, OwnerUnknown)
|
||||
func NewLink(shareUid string, canComment, canEdit bool) Link {
|
||||
return NewUserLink(shareUid, OwnerUnknown)
|
||||
}
|
||||
|
||||
// NewUserLink creates a sharing link owned by a user.
|
||||
func NewUserLink(shareUID string, canComment, canEdit bool, userUID string) Link {
|
||||
func NewUserLink(shareUid, userUid string) Link {
|
||||
now := TimeStamp()
|
||||
|
||||
result := Link{
|
||||
OwnerUID: userUID,
|
||||
LinkUID: rnd.GenerateUID(LinkUID),
|
||||
ShareUID: shareUID,
|
||||
ShareUID: shareUid,
|
||||
LinkToken: rnd.GenerateToken(10),
|
||||
CanComment: canComment,
|
||||
CanEdit: canEdit,
|
||||
CreatedBy: userUid,
|
||||
CreatedAt: now,
|
||||
ModifiedAt: now,
|
||||
}
|
||||
|
@ -71,35 +77,48 @@ func NewUserLink(shareUID string, canComment, canEdit bool, userUID string) Link
|
|||
return result
|
||||
}
|
||||
|
||||
func (m *Link) Redeem() {
|
||||
// Redeem increases the number of link visitors by one.
|
||||
func (m *Link) Redeem() *Link {
|
||||
m.LinkViews += 1
|
||||
|
||||
result := Db().Model(m).UpdateColumn("LinkViews", m.LinkViews)
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
log.Warnf("link: failed updating share view counter for %s", m.LinkUID)
|
||||
if err := Db().Model(m).UpdateColumn("link_views", gorm.Expr("link_views + 1")).Error; err != nil {
|
||||
event.AuditWarn([]string{"link %s", "failed to update view counter"}, clean.Log(m.RefID), err)
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// ExpiresAt returns the time when the share link expires or nil if it never expires.
|
||||
func (m *Link) ExpiresAt() *time.Time {
|
||||
if m.LinkExpires <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
expires := TimeStamp()
|
||||
expires = m.ModifiedAt.Add(Seconds(m.LinkExpires))
|
||||
|
||||
return &expires
|
||||
}
|
||||
|
||||
// Expired checks if the share link has expired.
|
||||
func (m *Link) Expired() bool {
|
||||
if m.MaxViews > 0 && m.LinkViews >= m.MaxViews {
|
||||
return true
|
||||
}
|
||||
|
||||
if m.LinkExpires <= 0 {
|
||||
if expires := m.ExpiresAt(); expires == nil {
|
||||
return false
|
||||
} else {
|
||||
return TimeStamp().After(*expires)
|
||||
}
|
||||
|
||||
now := TimeStamp()
|
||||
expires := m.ModifiedAt.Add(Seconds(m.LinkExpires))
|
||||
|
||||
return now.After(expires)
|
||||
}
|
||||
|
||||
// SetSlug sets the URL slug of the link.
|
||||
func (m *Link) SetSlug(s string) {
|
||||
m.ShareSlug = txt.Slug(s)
|
||||
}
|
||||
|
||||
// SetPassword sets the password required to use the share link.
|
||||
func (m *Link) SetPassword(password string) error {
|
||||
pw := NewPassword(m.LinkUID, password)
|
||||
|
||||
|
@ -112,6 +131,7 @@ func (m *Link) SetPassword(password string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// InvalidPassword checks if the password provided to use the share link is invalid.
|
||||
func (m *Link) InvalidPassword(password string) bool {
|
||||
if !m.HasPassword {
|
||||
return false
|
||||
|
@ -126,14 +146,14 @@ func (m *Link) InvalidPassword(password string) bool {
|
|||
return pw.InvalidPassword(password)
|
||||
}
|
||||
|
||||
// Save inserts a new row to the database or updates a row if the primary key already exists.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Link) Save() error {
|
||||
if !rnd.IsUID(m.ShareUID, 0) {
|
||||
return fmt.Errorf("link: invalid share uid (%s)", m.ShareUID)
|
||||
return fmt.Errorf("invalid share uid")
|
||||
}
|
||||
|
||||
if m.LinkToken == "" {
|
||||
return fmt.Errorf("link: empty share token")
|
||||
return fmt.Errorf("empty link token")
|
||||
}
|
||||
|
||||
m.ModifiedAt = TimeStamp()
|
||||
|
@ -141,40 +161,43 @@ func (m *Link) Save() error {
|
|||
return Db().Save(m).Error
|
||||
}
|
||||
|
||||
// Delete the link.
|
||||
// Delete permanently deletes the link.
|
||||
func (m *Link) Delete() error {
|
||||
if m.LinkToken == "" {
|
||||
return fmt.Errorf("link: empty share token")
|
||||
return fmt.Errorf("empty link token")
|
||||
}
|
||||
|
||||
return Db().Delete(m).Error
|
||||
}
|
||||
|
||||
// DeleteShareLinks removed all links matching the share uid.
|
||||
func DeleteShareLinks(shareUID string) error {
|
||||
return Db().Delete(&Link{}, "share_uid = ?", shareUID).Error
|
||||
// DeleteShareLinks removes all links that match the shared UID.
|
||||
func DeleteShareLinks(shareUid string) error {
|
||||
return Db().Delete(&Link{}, "share_uid = ?", shareUid).Error
|
||||
}
|
||||
|
||||
// FindLink returns an entity pointer if exists.
|
||||
func FindLink(linkUID string) *Link {
|
||||
result := Link{}
|
||||
|
||||
if err := Db().Where("link_uid = ?", linkUID).First(&result).Error; err == nil {
|
||||
return &result
|
||||
} else {
|
||||
log.Errorf("link: %s (not found)", err)
|
||||
// FindLink finds the link with the specified UID or nil if it is not found.
|
||||
func FindLink(linkUid string) *Link {
|
||||
if rnd.InvalidUID(linkUid, LinkUID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
result := Link{}
|
||||
|
||||
if Db().Where("link_uid = ?", linkUid).First(&result).RecordNotFound() {
|
||||
event.AuditWarn([]string{"link %s", "not found"}, clean.Log(linkUid))
|
||||
return nil
|
||||
}
|
||||
|
||||
return &result
|
||||
}
|
||||
|
||||
// FindLinks returns a slice of links for a token and share UID (at least one must be provided).
|
||||
func FindLinks(token, share string) (result Links) {
|
||||
// FindLinks returns a slice of links for a token and a share UID (at least one must be specified).
|
||||
func FindLinks(token, shared string) (found Links) {
|
||||
found = Links{}
|
||||
token = clean.ShareToken(token)
|
||||
|
||||
if token == "" && share == "" {
|
||||
log.Errorf("link: share token and uid must not be empty at the same time (find links)")
|
||||
return []Link{}
|
||||
if token == "" && shared == "" {
|
||||
return found
|
||||
}
|
||||
|
||||
q := Db()
|
||||
|
@ -183,33 +206,37 @@ func FindLinks(token, share string) (result Links) {
|
|||
q = q.Where("link_token = ?", token)
|
||||
}
|
||||
|
||||
if share != "" {
|
||||
if rnd.IsUID(share, 'a') {
|
||||
q = q.Where("share_uid = ?", share)
|
||||
if shared != "" {
|
||||
if rnd.IsUID(shared, 0) {
|
||||
q = q.Where("share_uid = ?", shared)
|
||||
} else {
|
||||
q = q.Where("share_slug = ?", share)
|
||||
q = q.Where("share_slug = ?", shared)
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.Order("modified_at DESC").Find(&result).Error; err != nil {
|
||||
log.Errorf("link: %s (not found)", err)
|
||||
if err := q.Order("modified_at DESC").Find(&found).Error; err != nil {
|
||||
event.AuditErr([]string{"token %s", "%s"}, clean.Log(token), err)
|
||||
}
|
||||
|
||||
return result
|
||||
return found
|
||||
}
|
||||
|
||||
// FindValidLinks returns a slice of non-expired links for a token and share UID (at least one must be provided).
|
||||
func FindValidLinks(token, share string) (result Links) {
|
||||
for _, link := range FindLinks(token, share) {
|
||||
if !link.Expired() {
|
||||
result = append(result, link)
|
||||
func FindValidLinks(token, shared string) (found Links) {
|
||||
found = Links{}
|
||||
|
||||
for _, link := range FindLinks(token, shared) {
|
||||
if link.Expired() {
|
||||
continue
|
||||
}
|
||||
|
||||
found = append(found, link)
|
||||
}
|
||||
|
||||
return result
|
||||
return found
|
||||
}
|
||||
|
||||
// String returns an human readable identifier for logging.
|
||||
// String returns a human-readable identifier for use in logs.
|
||||
func (m *Link) String() string {
|
||||
return clean.Log(m.LinkUID)
|
||||
}
|
||||
|
|
|
@ -16,8 +16,6 @@ var LinkFixtures = LinkMap{
|
|||
LinkViews: 12,
|
||||
MaxViews: 0,
|
||||
HasPassword: false,
|
||||
CanComment: true,
|
||||
CanEdit: false,
|
||||
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
ModifiedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
},
|
||||
|
@ -30,8 +28,6 @@ var LinkFixtures = LinkMap{
|
|||
LinkViews: 0,
|
||||
MaxViews: 0,
|
||||
HasPassword: false,
|
||||
CanComment: true,
|
||||
CanEdit: false,
|
||||
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
ModifiedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
},
|
||||
|
@ -44,8 +40,6 @@ var LinkFixtures = LinkMap{
|
|||
LinkViews: 0,
|
||||
MaxViews: 0,
|
||||
HasPassword: false,
|
||||
CanComment: true,
|
||||
CanEdit: false,
|
||||
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
ModifiedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
},
|
||||
|
@ -58,8 +52,6 @@ var LinkFixtures = LinkMap{
|
|||
LinkViews: 0,
|
||||
MaxViews: 0,
|
||||
HasPassword: false,
|
||||
CanComment: true,
|
||||
CanEdit: false,
|
||||
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
ModifiedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
},
|
||||
|
@ -72,8 +64,6 @@ var LinkFixtures = LinkMap{
|
|||
LinkViews: 0,
|
||||
MaxViews: 0,
|
||||
HasPassword: false,
|
||||
CanComment: true,
|
||||
CanEdit: false,
|
||||
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
ModifiedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
|
||||
},
|
||||
|
|
|
@ -10,8 +10,6 @@ import (
|
|||
func TestNewLink(t *testing.T) {
|
||||
link := NewLink("st9lxuqxpogaaba1", true, false)
|
||||
assert.Equal(t, "st9lxuqxpogaaba1", link.ShareUID)
|
||||
assert.Equal(t, false, link.CanEdit)
|
||||
assert.Equal(t, true, link.CanComment)
|
||||
assert.Equal(t, 10, len(link.LinkToken))
|
||||
assert.Equal(t, 16, len(link.LinkUID))
|
||||
}
|
||||
|
@ -46,7 +44,7 @@ func TestLink_Expired(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLink_Redeem(t *testing.T) {
|
||||
link := NewLink(rnd.GenerateUID('a'), false, false)
|
||||
link := NewLink(rnd.GenerateUID(AlbumUID), false, false)
|
||||
|
||||
assert.Equal(t, uint(0), link.LinkViews)
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/crop"
|
||||
"github.com/photoprism/photoprism/internal/face"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
@ -26,14 +25,14 @@ const (
|
|||
|
||||
// Marker represents an image marker point.
|
||||
type Marker struct {
|
||||
MarkerUID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
|
||||
FileUID string `gorm:"type:VARBINARY(64);index;default:'';" json:"FileUID" yaml:"FileUID"`
|
||||
MarkerUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"UID" yaml:"UID"`
|
||||
FileUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"FileUID" yaml:"FileUID"`
|
||||
MarkerType string `gorm:"type:VARBINARY(8);default:'';" json:"Type" yaml:"Type"`
|
||||
MarkerSrc string `gorm:"type:VARBINARY(8);default:'';" json:"Src" yaml:"Src,omitempty"`
|
||||
MarkerName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name,omitempty"`
|
||||
MarkerReview bool `json:"Review" yaml:"Review,omitempty"`
|
||||
MarkerInvalid bool `json:"Invalid" yaml:"Invalid,omitempty"`
|
||||
SubjUID string `gorm:"type:VARBINARY(64);index:idx_markers_subj_uid_src;" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
||||
SubjUID string `gorm:"type:VARBINARY(42);index:idx_markers_subj_uid_src;" json:"SubjUID" yaml:"SubjUID,omitempty"`
|
||||
SubjSrc string `gorm:"type:VARBINARY(8);index:idx_markers_subj_uid_src;default:'';" json:"SubjSrc" yaml:"SubjSrc,omitempty"`
|
||||
subject *Subject `gorm:"foreignkey:SubjUID;association_foreignkey:SubjUID;association_autoupdate:false;association_autocreate:false;association_save_reference:false"`
|
||||
FaceID string `gorm:"type:VARBINARY(64);index;" json:"FaceID" yaml:"FaceID,omitempty"`
|
||||
|
@ -99,8 +98,8 @@ func NewMarker(file File, area crop.Area, subjUID, markerSrc, markerType string,
|
|||
}
|
||||
|
||||
// NewFaceMarker creates a new entity.
|
||||
func NewFaceMarker(f face.Face, file File, subjUID string) *Marker {
|
||||
m := NewMarker(file, f.CropArea(), subjUID, SrcImage, MarkerFace, f.Size(), f.Score)
|
||||
func NewFaceMarker(f face.Face, file File, subjUid string) *Marker {
|
||||
m := NewMarker(file, f.CropArea(), subjUid, SrcImage, MarkerFace, f.Size(), f.Score)
|
||||
|
||||
// Failed creating new marker?
|
||||
if m == nil {
|
||||
|
@ -375,7 +374,7 @@ func (m *Marker) InvalidArea() error {
|
|||
return fmt.Errorf("invalid %s crop area x=%d%% y=%d%% w=%d%% h=%d%%", TypeString(m.MarkerType), int(m.X*100), int(m.Y*100), int(m.W*100), int(m.H*100))
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Marker) Save() error {
|
||||
if err := m.InvalidArea(); err != nil {
|
||||
return err
|
||||
|
|
|
@ -147,11 +147,11 @@ func (m *Markers) AppendWithEmbedding(marker Marker) {
|
|||
}
|
||||
|
||||
// FindMarkers returns up to 1000 markers for a given file uid.
|
||||
func FindMarkers(fileUID string) (Markers, error) {
|
||||
func FindMarkers(fileUid string) (Markers, error) {
|
||||
m := Markers{}
|
||||
|
||||
err := Db().
|
||||
Where("file_uid = ?", fileUID).
|
||||
Where("file_uid = ?", fileUid).
|
||||
Order("x").
|
||||
Offset(0).Limit(1000).
|
||||
Find(&m).Error
|
||||
|
|
|
@ -61,7 +61,7 @@ func (m *Password) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save inserts a new row to the database or updates a row if the primary key already exists.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Password) Save() error {
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ type Photo struct {
|
|||
TakenAt time.Time `gorm:"type:DATETIME;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"`
|
||||
TakenAtLocal time.Time `gorm:"type:DATETIME;" yaml:"-"`
|
||||
TakenSrc string `gorm:"type:VARBINARY(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"`
|
||||
PhotoUID string `gorm:"type:VARBINARY(64);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"`
|
||||
PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"`
|
||||
PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"`
|
||||
TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"`
|
||||
PhotoTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title"`
|
||||
|
@ -71,9 +71,9 @@ type Photo struct {
|
|||
PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"`
|
||||
PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"`
|
||||
TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"TimeZone,omitempty"`
|
||||
PlaceID string `gorm:"type:VARBINARY(64);index;default:'zz'" json:"PlaceID" yaml:"-"`
|
||||
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"`
|
||||
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"`
|
||||
CellID string `gorm:"type:VARBINARY(64);index;default:'zz'" json:"CellID" yaml:"-"`
|
||||
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"`
|
||||
CellAccuracy int `json:"CellAccuracy" yaml:"CellAccuracy,omitempty"`
|
||||
PhotoAltitude int `json:"Altitude" yaml:"Altitude,omitempty"`
|
||||
PhotoLat float32 `gorm:"type:FLOAT;index;" json:"Lat" yaml:"Lat,omitempty"`
|
||||
|
@ -103,10 +103,11 @@ type Photo struct {
|
|||
Albums []Album `json:"-" yaml:"-"`
|
||||
Files []File `yaml:"-"`
|
||||
Labels []PhotoLabel `yaml:"-"`
|
||||
OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"`
|
||||
CreatedBy string `gorm:"type:VARBINARY(42);index" json:"CreatedBy,omitempty" yaml:"CreatedBy,omitempty"`
|
||||
CreatedAt time.Time `yaml:"CreatedAt,omitempty"`
|
||||
UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"`
|
||||
EditedAt *time.Time `yaml:"EditedAt,omitempty"`
|
||||
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
||||
CheckedAt *time.Time `sql:"index" yaml:"-"`
|
||||
EstimatedAt *time.Time `json:"EstimatedAt,omitempty" yaml:"-"`
|
||||
DeletedAt *time.Time `sql:"index" yaml:"DeletedAt,omitempty"`
|
||||
|
@ -123,9 +124,8 @@ func NewPhoto(stackable bool) Photo {
|
|||
}
|
||||
|
||||
// NewUserPhoto creates a photo owned by a user.
|
||||
func NewUserPhoto(stackable bool, userUID string) Photo {
|
||||
func NewUserPhoto(stackable bool, userUid string) Photo {
|
||||
m := Photo{
|
||||
OwnerUID: userUID,
|
||||
PhotoTitle: UnknownTitle,
|
||||
PhotoType: MediaImage,
|
||||
PhotoCountry: UnknownCountry.ID,
|
||||
|
@ -137,6 +137,7 @@ func NewUserPhoto(stackable bool, userUID string) Photo {
|
|||
Lens: &UnknownLens,
|
||||
Cell: &UnknownLocation,
|
||||
Place: &UnknownPlace,
|
||||
CreatedBy: userUid,
|
||||
}
|
||||
|
||||
if stackable {
|
||||
|
@ -258,7 +259,7 @@ func (m *Photo) Create() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Save updates an existing photo or inserts a new one.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Photo) Save() error {
|
||||
photoMutex.Lock()
|
||||
defer photoMutex.Unlock()
|
||||
|
@ -274,7 +275,7 @@ func (m *Photo) Save() error {
|
|||
return m.ResolvePrimary()
|
||||
}
|
||||
|
||||
// FindPhoto fetches the matching photo record.
|
||||
// FindPhoto fetches the matching record or returns null if it was not found.
|
||||
func FindPhoto(find Photo) *Photo {
|
||||
if find.PhotoUID == "" && find.ID == 0 {
|
||||
return nil
|
||||
|
@ -906,14 +907,14 @@ func (m *Photo) PrimaryFile() (*File, error) {
|
|||
}
|
||||
|
||||
// SetPrimary sets a new primary file.
|
||||
func (m *Photo) SetPrimary(fileUID string) (err error) {
|
||||
func (m *Photo) SetPrimary(fileUid string) (err error) {
|
||||
if m.PhotoUID == "" {
|
||||
return fmt.Errorf("photo uid is empty")
|
||||
}
|
||||
|
||||
var files []string
|
||||
|
||||
if fileUID != "" {
|
||||
if fileUid != "" {
|
||||
// Do nothing.
|
||||
} else if err = Db().Model(File{}).
|
||||
Where("photo_uid = ? AND file_type = 'jpg' AND file_missing = 0 AND file_error = ''", m.PhotoUID).
|
||||
|
@ -923,18 +924,18 @@ func (m *Photo) SetPrimary(fileUID string) (err error) {
|
|||
} else if len(files) == 0 {
|
||||
return fmt.Errorf("found no jpeg for photo uid %s", clean.Log(m.PhotoUID))
|
||||
} else {
|
||||
fileUID = files[0]
|
||||
fileUid = files[0]
|
||||
}
|
||||
|
||||
if fileUID == "" {
|
||||
if fileUid == "" {
|
||||
return fmt.Errorf("file uid is empty")
|
||||
}
|
||||
|
||||
if err = Db().Model(File{}).
|
||||
Where("photo_uid = ? AND file_uid <> ?", m.PhotoUID, fileUID).
|
||||
Where("photo_uid = ? AND file_uid <> ?", m.PhotoUID, fileUid).
|
||||
UpdateColumn("file_primary", 0).Error; err != nil {
|
||||
return err
|
||||
} else if err = Db().Model(File{}).Where("photo_uid = ? AND file_uid = ?", m.PhotoUID, fileUID).
|
||||
} else if err = Db().Model(File{}).Where("photo_uid = ? AND file_uid = ?", m.PhotoUID, fileUid).
|
||||
UpdateColumn("file_primary", 1).Error; err != nil {
|
||||
return err
|
||||
} else if m.PhotoQuality < 0 {
|
||||
|
|
|
@ -8,8 +8,8 @@ type PhotoAlbums []PhotoAlbum
|
|||
|
||||
// PhotoAlbum represents the many_to_many relation between Photo and Album
|
||||
type PhotoAlbum struct {
|
||||
PhotoUID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false" json:"PhotoUID" yaml:"UID"`
|
||||
AlbumUID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;index" json:"AlbumUID" yaml:"-"`
|
||||
PhotoUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false" json:"PhotoUID" yaml:"UID"`
|
||||
AlbumUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;index" json:"AlbumUID" yaml:"-"`
|
||||
Order int `json:"Order" yaml:"Order,omitempty"`
|
||||
Hidden bool `json:"Hidden" yaml:"Hidden,omitempty"`
|
||||
Missing bool `json:"Missing" yaml:"Missing,omitempty"`
|
||||
|
@ -24,11 +24,11 @@ func (PhotoAlbum) TableName() string {
|
|||
return "photos_albums"
|
||||
}
|
||||
|
||||
// NewPhotoAlbum registers an photo and album association using UID
|
||||
func NewPhotoAlbum(photoUID, albumUID string) *PhotoAlbum {
|
||||
// NewPhotoAlbum creates a new photo and album mapping with UIDs.
|
||||
func NewPhotoAlbum(photoUid, albumUid string) *PhotoAlbum {
|
||||
result := &PhotoAlbum{
|
||||
PhotoUID: photoUID,
|
||||
AlbumUID: albumUID,
|
||||
PhotoUID: photoUid,
|
||||
AlbumUID: albumUid,
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
@ -4,20 +4,20 @@ import "time"
|
|||
|
||||
type PhotoAlbumMap map[string]PhotoAlbum
|
||||
|
||||
func (m PhotoAlbumMap) Get(name, photoUID, albumUID string) PhotoAlbum {
|
||||
func (m PhotoAlbumMap) Get(name, photoUid, albumUid string) PhotoAlbum {
|
||||
if result, ok := m[name]; ok {
|
||||
return result
|
||||
}
|
||||
|
||||
return *NewPhotoAlbum(photoUID, albumUID)
|
||||
return *NewPhotoAlbum(photoUid, albumUid)
|
||||
}
|
||||
|
||||
func (m PhotoAlbumMap) Pointer(name, photoUID, albumUID string) *PhotoAlbum {
|
||||
func (m PhotoAlbumMap) Pointer(name, photoUid, albumUid string) *PhotoAlbum {
|
||||
if result, ok := m[name]; ok {
|
||||
return &result
|
||||
}
|
||||
|
||||
return NewPhotoAlbum(photoUID, albumUID)
|
||||
return NewPhotoAlbum(photoUid, albumUid)
|
||||
}
|
||||
|
||||
var PhotoAlbumFixtures = PhotoAlbumMap{
|
||||
|
|
56
internal/entity/photo_auth.go
Normal file
56
internal/entity/photo_auth.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package entity
|
||||
|
||||
import "github.com/photoprism/photoprism/internal/event"
|
||||
|
||||
// PhotoAuth represents the ownership of a Photo and the corresponding permissions.
|
||||
type PhotoAuth struct {
|
||||
UID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false" json:"UID" yaml:"UID"`
|
||||
UserUID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;index" json:"UserUID" yaml:"UserUID"`
|
||||
TeamUID string `gorm:"type:VARBINARY(42);index" json:"TeamUID" yaml:"TeamUID"`
|
||||
Perm uint `json:"Perm,omitempty" yaml:"Perm,omitempty"`
|
||||
Changed int64 `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
// TableName returns the database table name.
|
||||
func (PhotoAuth) TableName() string {
|
||||
return "photos_auth_dev"
|
||||
}
|
||||
|
||||
// NewPhotoAuth creates a new entity model.
|
||||
func NewPhotoAuth(uid, userUid, teamUid string, perm uint) *PhotoAuth {
|
||||
result := &PhotoAuth{
|
||||
UID: uid,
|
||||
UserUID: userUid,
|
||||
TeamUID: teamUid,
|
||||
Perm: perm,
|
||||
Changed: TimeStamp().Unix(),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Create inserts a new record into the database.
|
||||
func (m *PhotoAuth) Create() error {
|
||||
m.Changed = TimeStamp().Unix()
|
||||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *PhotoAuth) Save() error {
|
||||
m.Changed = TimeStamp().Unix()
|
||||
return Db().Save(m).Error
|
||||
}
|
||||
|
||||
// FirstOrCreatePhotoUser returns the existing record or inserts a new record if it does not already exist.
|
||||
func FirstOrCreatePhotoUser(m *PhotoAuth) *PhotoAuth {
|
||||
found := PhotoAuth{}
|
||||
|
||||
if err := Db().Where("uid = ?", m.UID).First(&found).Error; err == nil {
|
||||
return &found
|
||||
} else if err = m.Create(); err != nil {
|
||||
event.AuditErr([]string{"photo %s", "failed to set owner and permissions", "%s"}, m.UID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
|
@ -44,7 +44,7 @@ func (m *PhotoLabel) Update(attr string, value interface{}) error {
|
|||
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
||||
}
|
||||
|
||||
// Save saves the entity in the database.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *PhotoLabel) Save() error {
|
||||
if m.Photo != nil {
|
||||
m.Photo = nil
|
||||
|
|
|
@ -13,7 +13,7 @@ var placeMutex = sync.Mutex{}
|
|||
|
||||
// Place represents a distinct region identified by city, district, state, and country.
|
||||
type Place struct {
|
||||
ID string `gorm:"type:VARBINARY(64);primary_key;auto_increment:false;" json:"PlaceID" yaml:"PlaceID"`
|
||||
ID string `gorm:"type:VARBINARY(42);primary_key;auto_increment:false;" json:"PlaceID" yaml:"PlaceID"`
|
||||
PlaceLabel string `gorm:"type:VARCHAR(400);" json:"Label" yaml:"Label"`
|
||||
PlaceDistrict string `gorm:"type:VARCHAR(100);index;" json:"District" yaml:"District,omitempty"`
|
||||
PlaceCity string `gorm:"type:VARCHAR(100);index;" json:"City" yaml:"City,omitempty"`
|
||||
|
@ -69,7 +69,7 @@ func (m *Place) Create() error {
|
|||
return Db().Create(m).Error
|
||||
}
|
||||
|
||||
// Save updates the existing or inserts a new row.
|
||||
// Save updates the record in the database or inserts a new record if it does not already exist.
|
||||
func (m *Place) Save() error {
|
||||
placeMutex.Lock()
|
||||
defer placeMutex.Unlock()
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue