Auth: Open album share links in the regular user interface #98 #782

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-10-02 11:38:30 +02:00
parent a5f2c5e109
commit 6e74f16a77
131 changed files with 1646 additions and 2974 deletions

View file

@ -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>

View file

@ -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" .}}

View file

@ -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();
}

View file

@ -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",

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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() {

View file

@ -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': [

View file

@ -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>

View file

@ -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;
}

View file

@ -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 {

View file

@ -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)));
}

View file

@ -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"

View file

@ -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,

View file

@ -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);

View file

@ -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";

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">&copy; MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; 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>

View file

@ -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" },
},
];

View file

@ -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
);

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -5,6 +5,10 @@ var Events = ACL{
ResourceDefault: Roles{
RoleAdmin: GrantFullAccess,
},
ChannelUser: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSubscribeOwn,
},
ChannelSession: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSubscribeOwn,

View file

@ -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,

View file

@ -1,6 +1,7 @@
package acl
const (
ChannelUser Resource = "user"
ChannelSession Resource = "session"
ChannelAudit Resource = "audit"
ChannelLog Resource = "log"

View file

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

View file

@ -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()

View file

@ -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

View file

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

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

View file

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

View file

@ -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"}`)

View file

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

View file

@ -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)

View file

@ -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

View file

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

View file

@ -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 {

View file

@ -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.

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

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

View file

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

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

View file

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

View file

@ -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.

View file

@ -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,
},
}

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

View file

@ -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)

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

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

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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:"-"`

View file

@ -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)")

View file

@ -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 {

View file

@ -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.

View file

@ -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())

View file

@ -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)

View file

@ -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 {

View file

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

View file

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

View file

@ -31,4 +31,5 @@ func CreateTestFixtures() {
CreateSessionFixtures()
CreateReactionFixtures()
CreatePasswordFixtures()
CreateShareFixtures()
}

View file

@ -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:"-"`
}

View file

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

View file

@ -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()

View file

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

View file

@ -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),
},

View file

@ -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)

View file

@ -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

View file

@ -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

View file

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

View file

@ -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 {

View file

@ -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

View file

@ -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{

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

View file

@ -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

View file

@ -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