Improve link sharing dialog and api #18

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-06-22 15:16:26 +02:00
parent dc28b35b71
commit 722d7dd421
38 changed files with 1651 additions and 1799 deletions

File diff suppressed because it is too large Load diff

View file

@ -18,17 +18,17 @@
"gettext-compile": "gettext-compile --output src/resources/translations.json src/resources/*.po"
},
"dependencies": {
"@babel/cli": "^7.10.1",
"@babel/core": "^7.10.2",
"@babel/plugin-transform-runtime": "^7.10.1",
"@babel/cli": "^7.10.3",
"@babel/core": "^7.10.3",
"@babel/plugin-transform-runtime": "^7.10.3",
"@babel/polyfill": "^7.10.1",
"@babel/preset-env": "^7.10.2",
"@babel/register": "^7.10.1",
"@babel/runtime": "^7.10.2",
"@fortawesome/fontawesome-free": "^5.13.0",
"@babel/preset-env": "^7.10.3",
"@babel/register": "^7.10.3",
"@babel/runtime": "^7.10.3",
"@fortawesome/fontawesome-free": "^5.13.1",
"acorn": "6.4.1",
"ajv": "^6.12.2",
"autoprefixer": "^9.8.0",
"autoprefixer": "^9.8.2",
"axios": "^0.19.2",
"axios-mock-adapter": "^1.18.1",
"babel-eslint": "^10.1.0",
@ -99,7 +99,7 @@
"sass-loader": "^7.3.1",
"sinon": "^9.0.2",
"sockette": "^2.0.6",
"string-strip-html": "^4.4.7",
"string-strip-html": "^4.5.0",
"style-loader": "^0.23.1",
"sugarss": "^2.0.0",
"svg-url-loader": "^5.0.1",
@ -121,7 +121,7 @@
"vuetify": "^1.5.24",
"webpack": "^4.43.0",
"webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.11",
"webpack-cli": "^3.3.12",
"webpack-hot-middleware": "^2.25.0",
"webpack-md5-hash": "0.0.6",
"webpack-merge": "^4.2.2"

View file

@ -23,7 +23,7 @@
fab dark small
:title="labels.share"
color="share"
@click.stop="dialog.share = true"
@click.stop="share()"
:disabled="selection.length !== 1"
v-if="$config.feature('share')"
class="action-share"
@ -75,7 +75,7 @@
@confirm="cloneAlbums"></p-photo-album-dialog>
<p-album-delete-dialog :show="dialog.delete" @cancel="dialog.delete = false"
@confirm="batchDelete"></p-album-delete-dialog>
<p-share-dialog :show="dialog.share" title="Share Album" :selection="selection" @upload="upload"
<p-share-dialog :show="dialog.share" title="Share Album" :model="model" @upload="upload"
@close="dialog.share = false"></p-share-dialog>
<p-share-upload-dialog :show="dialog.upload" :selection="selection" @cancel="dialog.upload = false"
@confirm="dialog.upload = false"></p-share-upload-dialog>
@ -84,6 +84,7 @@
<script>
import Api from "common/api";
import Notify from "common/notify";
import Album from "model/album";
export default {
name: 'p-album-clipboard',
@ -95,6 +96,7 @@
data() {
return {
expanded: false,
model: new Album(),
dialog: {
delete: false,
album: false,
@ -112,6 +114,20 @@
};
},
methods: {
share() {
if (this.selection.length !== 1) {
this.$notify.error("select one album to share");
return;
}
this.model = new Album();
this.model.find(this.selection[0]).then(
(m) => {
this.model = m;
this.dialog.share = true;
}
);
},
upload() {
this.dialog.share = false;
this.dialog.upload = true;

View file

@ -107,7 +107,7 @@ main {
}
#photoprism .p-log-empty,
#photoprism .p-log-message{
#photoprism .p-log-message {
display: block;
text-align: left;
font-size: 1em;
@ -188,8 +188,7 @@ table.v-table tbody td input,
table.v-table tbody td label.v-label,
table.v-table tfoot td,
table.v-table tfoot td input,
table.v-table tfoot td label.v-label
{
table.v-table tfoot td label.v-label {
font-weight: 400;
font-size: 13px;
}
@ -238,3 +237,7 @@ table.v-table tfoot td div.v-input__slot {
#photoprism .opacity-100 {
opacity: 1 !important;
}
#photoprism button:focus {
outline: none;
}

View file

@ -3,56 +3,82 @@
<v-card raised elevation="24">
<v-card-title primary-title class="pb-0">
<v-layout row wrap>
<v-flex xs9>
<h3 class="headline mb-0">{{ title }}</h3>
<v-flex xs8>
<h3 class="headline mb-0">Share<span class="hidden-xs-only"> {{model.modelName()}}</span></h3>
</v-flex>
<v-flex xs3 text-xs-right>
<v-btn small depressed color="secondary-light" class="ma-0">
Add Link
<v-flex xs4 text-xs-right>
<v-btn small depressed dark color="secondary-dark" class="ma-0" @click.stop="add()">
<translate>Add Link</translate>
</v-btn>
</v-flex>
</v-layout>
</v-card-title>
<v-card-text>
<v-expansion-panel class="pa-0 elevation-0">
<v-expansion-panel-content class="pa-0 elevation-0 secondary-light">
<v-expansion-panel-content v-for="(link, index) in links" :key="index"
class="pa-0 elevation-0 secondary-light mb-1">
<template v-slot:header>
<div class="action-url">
{{ host + '/s/a4bey45buvhg8vnxo4y'}}
</div>
<button class="text-xs-left action-url ml-0 mt-0 mb-0 pa-0 mr-2" @click.stop="copyUrl(link)">
<v-icon size="16" class="pr-1">link</v-icon>
/s/<strong style="font-weight: 500;" v-if="link.ShareToken">{{ link.ShareToken.toLowerCase() }}</strong><span v-else>...</span>
</button>
</template>
<v-card>
<v-card-text class="grey lighten-4">
<v-container fluid class="pa-0">
<v-layout row wrap>
<v-flex xs12 sm6 class="pa-2">
<v-flex xs12 class="pa-2">
<v-text-field
hide-details
:label="$gettext('URL')"
browser-autocomplete="off"
label="Token"
placeholder="a4bey45buvhg8vnxo4y"
hide-details readonly
color="secondary-dark"
v-model="model.AccURL"
></v-text-field>
@click.stop="selectText($event)"
v-model="link.url()">
</v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-select
:label="expires(link)"
browser-autocomplete="off"
hide-details
color="secondary-dark"
item-text="text"
item-value="value"
v-model="link.ShareExpires"
:items="items.expires">
</v-select>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field
hide-details required
browser-autocomplete="off"
:label="$gettext('Secret')"
:placeholder="$gettext('Token')"
color="secondary-dark"
v-model="link.ShareToken"
></v-text-field>
</v-flex>
<!-- v-flex xs12 sm6 class="pa-2">
<v-text-field
hide-details
browser-autocomplete="off"
:label="label.pass"
placeholder="optional"
:placeholder="link.HasPassword ? '••••••••' : 'optional'"
color="secondary-dark"
v-model="model.AccPass"
v-model="link.Password"
:append-icon="showPassword ? 'visibility' : 'visibility_off'"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword = !showPassword"
></v-text-field>
</v-flex>
</v-flex -->
<v-flex xs12 text-xs-right class="pa-2">
<v-btn small flat color="remove" class="ma-0">
Delete
<v-btn small flat color="remove" class="ma-0"
@click.stop.exact="remove(index)">
<v-icon>delete</v-icon>
</v-btn>
<v-btn small depressed dark color="secondary-dark" class="ma-0">
<v-btn small depressed dark color="secondary-dark" class="ma-0"
@click.stop.exact="update(link)">
Save
</v-btn>
</v-flex>
@ -62,31 +88,40 @@
</v-card>
</v-expansion-panel-content>
</v-expansion-panel>
<v-container fluid text-xs-left class="pb-0 pt-3 pr-0 pl-0 caption">
People you share a link with will be able to view the {{model.modelName().toLowerCase()}}.
A click will copy it to your clipboard. Any private photos remain private.
Alternatively, you can upload files directly to WebDAV servers like Nextcloud.
</v-container>
</v-card-text>
<v-card-actions>
<v-container fluid text-xs-right class="pt-0 pb-2 pr-2 pl-2">
<v-card-actions class="pt-0">
<v-layout row wrap class="pa-2">
<v-flex xs6>
<v-btn @click.stop="upload" depressed color="secondary-light"
class="action-webdav">
<v-icon left>cloud</v-icon>
<span>Upload</span>
<span>WebDAV Upload</span>
</v-btn>
<v-btn depressed dark color="secondary-dark" @click.stop="confirm"
</v-flex>
<v-flex xs6 text-xs-right>
<v-btn depressed color="secondary-light" @click.stop="confirm"
class="action-close">
<span>{{ label.confirm }}</span>
<translate>Close</translate>
</v-btn>
</v-container>
</v-flex>
</v-layout>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import Album from "model/album";
export default {
name: 'p-share-dialog',
props: {
show: Boolean,
title: String,
model: Object,
},
data() {
return {
@ -94,7 +129,19 @@
showPassword: false,
loading: false,
search: null,
model: new Album(),
links: [],
items: {
expires: [
{"value": 0, "text": "Never"},
{"value": 86400, "text": "After 1 day"},
{"value": 86400 * 3, "text": "After 3 days"},
{"value": 86400 * 7, "text": "After 7 days"},
{"value": 86400 * 14, "text": "After two weeks"},
{"value": 86400 * 31, "text": "After one month"},
{"value": 86400 * 60, "text": "After two months"},
{"value": 86400 * 365, "text": "After one year"},
],
},
label: {
url: this.$gettext("Service URL"),
user: this.$gettext("Username"),
@ -105,11 +152,55 @@
}
},
methods: {
selectText(ev) {
if(!ev || !ev.target) {
return;
}
ev.target.select();
},
copyUrl(link) {
window.navigator.clipboard.writeText(link.url())
.then(() => this.$notify.success(this.$gettext("Copied to clipboard")), () => this.$notify.error(this.$gettext("Failed copying to clipboard")));
},
expires(link) {
let result = this.$gettext('Expires');
if (link.ShareExpires <= 0) {
return result
}
return `${result}: ${link.expires()}`;
},
add() {
this.loading = true;
this.model.addLink().then((a) => {
this.$emit('close');
this.model.createLink().then((r) => {
this.links.push(r);
}).finally(() => this.loading = false)
},
update(link) {
if (!link) {
this.$notify.error(this.$gettext("Failed updating link"))
return;
}
this.loading = true;
this.model.updateLink(link).finally(() => this.loading = false);
},
remove(index) {
const link = this.links[index];
if (!link) {
this.$notify.error(this.$gettext("Failed removing link"))
return;
}
this.loading = true;
this.model.removeLink(link).then(() => {
this.links.splice(index, 1);
}).finally(() => this.loading = false)
},
upload() {
@ -124,7 +215,16 @@
},
watch: {
show: function (show) {
this.model = this.sele
if (show) {
this.loading = true;
this.model.links().then((resp) => {
if (resp.count === 0) {
this.add();
} else {
this.links = resp.models;
}
}).finally(() => this.loading = false);
}
}
},
}

View file

@ -4,7 +4,7 @@
<v-container fluid class="pb-2 pr-2 pl-2">
<v-layout row wrap>
<v-flex xs3 text-xs-center>
<v-icon size="54" color="grey lighten-1">cloud</v-icon>
<v-icon size="54" color="grey lighten-1">cloud_upload</v-icon>
</v-flex>
<v-flex xs9 text-xs-left align-self-center>
<v-select

View file

@ -1,4 +1,5 @@
import RestModel from "model/rest";
import Link from "model/link";
import Api from "common/api";
import {DateTime} from "luxon";
import {config} from "../session";
@ -26,7 +27,6 @@ export class Album extends RestModel {
Favorite: true,
Private: false,
PhotoCount: 0,
Links: [],
CreatedAt: "",
UpdatedAt: "",
};
@ -96,10 +96,6 @@ export class Album extends RestModel {
return Api.delete(this.getEntityResource() + "/like");
}
addLink(password, expires) {
return Api.post(this.getEntityResource() + "/links", {"Password": password ? password : "", "Expires": expires ? expires : 0, "CanEdit": false, "CanComment": false});
}
static getCollectionResource() {
return "albums";
}

View file

@ -37,7 +37,6 @@ export class File extends RestModel {
Chroma: 0,
Notes: "",
Error: "",
Links: [],
CreatedAt: "",
CreatedIn: 0,
UpdatedAt: "",

View file

@ -27,7 +27,6 @@ export class Folder extends RestModel {
Ignore: false,
Watch: false,
FileCount: 0,
Links: [],
CreatedAt: "",
UpdatedAt: "",
};

View file

@ -16,7 +16,6 @@ export class Label extends RestModel {
Description: "",
Notes: "",
PhotoCount: 0,
Links: [],
CreatedAt: "",
UpdatedAt: "",
DeletedAt: "",

View file

@ -1,22 +1,71 @@
import RestModel from "model/rest";
import Model from "./model";
import Api from "../common/api";
import {DateTime} from "luxon";
export class Link extends RestModel {
export default class Link extends Model {
getDefaults() {
return {
Token: "",
Password: "",
Expires: "",
UID: "",
ShareUID: "",
ShareToken: "",
ShareExpires: 0,
Password: "",
HasPassword: false,
CanComment: false,
CanEdit: false,
CreatedAt: "",
UpdatedAt: "",
Links: [],
};
}
url() {
let token = this.ShareToken.toLowerCase();
if(!token) {
token = "...";
}
return `${window.location.origin}/s/${token}/${this.ShareUID}`;
}
caption() {
return `/s/${this.ShareToken.toLowerCase()}`;
}
getId() {
return this.Token;
return this.UID;
}
hasId() {
return !!this.getId();
}
clone() {
return new this.constructor(this.getValues());
}
find(id, params) {
return Api.get(this.getEntityResource(id), params).then((response) => Promise.resolve(new this.constructor(response.data)));
}
save() {
if (this.hasId()) {
return this.update();
}
return Api.post(this.constructor.getCollectionResource(), this.getValues()).then((response) => Promise.resolve(this.setValues(response.data)));
}
update() {
return Api.put(this.getEntityResource(), this.getValues(true)).then((response) => Promise.resolve(this.setValues(response.data)));
}
remove() {
return Api.delete(this.getEntityResource()).then(() => Promise.resolve(this));
}
expires() {
return DateTime.fromISO(this.UpdatedAt).plus({ seconds: this.ShareExpires }).toLocaleString(DateTime.DATE_SHORT);
}
static getCollectionResource() {
@ -27,5 +76,3 @@ export class Link extends RestModel {
return "Link";
}
}
export default Link;

View file

@ -65,7 +65,6 @@ export class Photo extends RestModel {
Labels: [],
Keywords: [],
Albums: [],
Links: [],
Location: {},
Place: {},
PlaceID: "",

View file

@ -1,6 +1,7 @@
import Api from "common/api";
import Form from "common/form";
import Model from "./model";
import Link from "./link";
export class Rest extends Model {
getId() {
@ -16,7 +17,7 @@ export class Rest extends Model {
}
find(id, params) {
return Api.get(this.getEntityResource(id), params).then((response) => Promise.resolve(new this.constructor(response.data)));
return Api.get(this.getEntityResource(id), params).then((resp) => Promise.resolve(new this.constructor(resp.data)));
}
save() {
@ -24,11 +25,11 @@ export class Rest extends Model {
return this.update();
}
return Api.post(this.constructor.getCollectionResource(), this.getValues()).then((response) => Promise.resolve(this.setValues(response.data)));
return Api.post(this.constructor.getCollectionResource(), this.getValues()).then((resp) => Promise.resolve(this.setValues(resp.data)));
}
update() {
return Api.put(this.getEntityResource(), this.getValues(true)).then((response) => Promise.resolve(this.setValues(response.data)));
return Api.put(this.getEntityResource(), this.getValues(true)).then((resp) => Promise.resolve(this.setValues(resp.data)));
}
remove() {
@ -36,7 +37,7 @@ export class Rest extends Model {
}
getEditForm() {
return Api.options(this.getEntityResource()).then(response => Promise.resolve(new Form(response.data)));
return Api.options(this.getEntityResource()).then(resp => Promise.resolve(new Form(resp.data)));
}
getEntityResource(id) {
@ -51,12 +52,50 @@ export class Rest extends Model {
return this.constructor.getModelName() + " " + this.getId();
}
addLink(password, expires, comment, edit) {
expires = expires ? parseInt(expires) : 0;
comment = !!comment;
edit = !!edit;
const values = {password, expires, comment, edit};
return Api.post(this.getEntityResource() + "/link", values).then((response) => Promise.resolve(this.setValues(response.data)));
createLink(password, expires) {
return Api
.post(this.getEntityResource() + "/links", {
"Password": password ? password : "",
"ShareExpires": expires ? expires : 0,
"CanEdit": false,
"CanComment": false
})
.then((resp) => Promise.resolve(new Link(resp.data)));
}
updateLink(link) {
let values = link.getValues(false);
if(link.Password) {
values["Password"] = link.Password;
}
return Api
.put(this.getEntityResource() + "/links/" + link.getId(), values)
.then((resp) => Promise.resolve(link.setValues(resp.data)));
}
removeLink(link) {
return Api
.delete(this.getEntityResource() + "/links/" + link.getId())
.then((resp) => Promise.resolve(link.setValues(resp.data)));
}
links() {
return Api.get(this.getEntityResource() + "/links").then((resp) => {
resp.models = [];
resp.count = resp.data.length;
for (let i = 0; i < resp.data.length; i++) {
resp.models.push(new Link(resp.data[i]));
}
return Promise.resolve(resp);
});
}
modelName() {
return this.constructor.getModelName()
}
static getCollectionResource() {
@ -68,7 +107,7 @@ export class Rest extends Model {
}
static getCreateForm() {
return Api.options(this.getCreateResource()).then(response => Promise.resolve(new Form(response.data)));
return Api.options(this.getCreateResource()).then(resp => Promise.resolve(new Form(resp.data)));
}
static getModelName() {
@ -76,7 +115,7 @@ export class Rest extends Model {
}
static getSearchForm() {
return Api.options(this.getCollectionResource()).then(response => Promise.resolve(new Form(response.data)));
return Api.options(this.getCollectionResource()).then(resp => Promise.resolve(new Form(resp.data)));
}
static search(params) {
@ -84,35 +123,35 @@ export class Rest extends Model {
params: params,
};
return Api.get(this.getCollectionResource(), options).then((response) => {
let count = response.data.length;
return Api.get(this.getCollectionResource(), options).then((resp) => {
let count = resp.data.length;
let limit = 0;
let offset = 0;
if (response.headers) {
if (response.headers["x-count"]) {
count = parseInt(response.headers["x-count"]);
if (resp.headers) {
if (resp.headers["x-count"]) {
count = parseInt(resp.headers["x-count"]);
}
if (response.headers["x-limit"]) {
limit = parseInt(response.headers["x-limit"]);
if (resp.headers["x-limit"]) {
limit = parseInt(resp.headers["x-limit"]);
}
if (response.headers["x-offset"]) {
offset = parseInt(response.headers["x-offset"]);
if (resp.headers["x-offset"]) {
offset = parseInt(resp.headers["x-offset"]);
}
}
response.models = [];
response.count = count;
response.limit = limit;
response.offset = offset;
resp.models = [];
resp.count = count;
resp.limit = limit;
resp.offset = offset;
for (let i = 0; i < response.data.length; i++) {
response.models.push(new this(response.data[i]));
for (let i = 0; i < resp.data.length; i++) {
resp.models.push(new this(resp.data[i]));
}
return Promise.resolve(response);
return Promise.resolve(resp);
});
}
}

View file

@ -5,10 +5,7 @@ import (
"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/query"
"github.com/photoprism/photoprism/pkg/txt"
)
// GET /api/v1/files/:hash
@ -32,34 +29,3 @@ func GetFile(router *gin.RouterGroup, conf *config.Config) {
c.JSON(http.StatusOK, p)
})
}
// POST /api/v1/files/:uid/link
//
// Parameters:
// uid: string SHA-1 hash of the file
func LinkFile(router *gin.RouterGroup, conf *config.Config) {
router.POST("/files/:uid/link", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
m, err := query.FileByUID(c.Param("uid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrFileNotFound)
return
}
if link, err := newLink(c); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
} else {
entity.Db().Model(&m).Association("Links").Append(link)
}
event.Success("created file share link")
c.JSON(http.StatusOK, m)
})
}

View file

@ -1,12 +1,11 @@
package api
import (
"encoding/json"
"github.com/photoprism/photoprism/internal/entity"
"github.com/tidwall/gjson"
"net/http"
"testing"
"github.com/tidwall/gjson"
"github.com/stretchr/testify/assert"
)
@ -27,42 +26,3 @@ func TestGetFile(t *testing.T) {
assert.Equal(t, http.StatusNotFound, r.Code)
})
}
func TestLinkFile(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkFile(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/files/ft9es39w45bnlqdw/link", `{"Password": "foobar123", "Expires": 0, "CanEdit": true}`)
var label entity.Label
if err := json.Unmarshal(r.Body.Bytes(), &label); err != nil {
t.Fatal(err)
}
if len(label.Links) != 1 {
t.Fatalf("one link expected: %d, %+v", len(label.Links), label)
}
link := label.Links[0]
assert.Equal(t, "foobar123", link.LinkPassword)
assert.Nil(t, link.LinkExpires)
assert.False(t, link.CanComment)
assert.True(t, link.CanEdit)
})
t.Run("file not found", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkFile(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/files/xxx/link", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusNotFound, r.Code)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "File not found", val.String())
})
t.Run("invalid request", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkFile(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/files/ft9es39w45bnlqdw/link", `{"xxx": 123, "Expires": 0, "CanEdit": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -2,7 +2,7 @@ package api
import (
"net/http"
"time"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
@ -13,32 +13,130 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// newLink returns a new link entity initialized with request data
func newLink(c *gin.Context) (link entity.Link, err error) {
var f form.Link
if err := c.BindJSON(&f); err != nil {
return link, err
}
link = entity.NewLink(f.Password, f.CanComment, f.CanEdit)
if f.Expires > 0 {
expires := time.Now().Add(time.Duration(f.Expires) * time.Second)
link.LinkExpires = &expires
}
return link, nil
}
// POST /api/v1/albums/:uid/link
func LinkAlbum(router *gin.RouterGroup, conf *config.Config) {
router.POST("/albums/:uid/links", func(c *gin.Context) {
// PUT /api/v1/:entity/:uid/links/:link
func UpdateLink(c *gin.Context, conf *config.Config) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
var f form.Link
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
}
link := entity.FindLink(c.Param("link"))
link.ShareExpires = f.ShareExpires
if f.ShareToken != "" {
link.ShareToken = strings.ToLower(f.ShareToken)
}
if f.Password != "" {
if err := link.SetPassword(f.Password); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
}
if err := link.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
event.Success("updated share link")
c.JSON(http.StatusOK, link)
}
// DELETE /api/v1/:entity/:uid/links/:link
func DeleteLink(c *gin.Context, conf *config.Config) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
link := entity.FindLink(c.Param("link"))
if err := link.Delete(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
event.Success("deleted share link")
c.JSON(http.StatusOK, link)
}
// CreateLink returns a new link entity initialized with request data
func CreateLink(c *gin.Context, conf *config.Config) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
var f form.Link
if err := c.BindJSON(&f); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
}
link := entity.NewLink(c.Param("uid"), f.CanComment, f.CanEdit)
if f.ShareExpires > 0 {
link.ShareExpires = f.ShareExpires
}
if f.Password != "" {
if err := link.SetPassword(f.Password); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
}
if err := link.Save(); err != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UcFirst(err.Error())})
return
}
event.Success("added share link")
c.JSON(http.StatusOK, link)
}
// POST /api/v1/albums/:uid/links
func CreateAlbumLink(router *gin.RouterGroup, conf *config.Config) {
router.POST("/albums/:uid/links", func(c *gin.Context) {
if _, err := query.AlbumByUID(c.Param("uid")); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound)
return
}
CreateLink(c, conf)
})
}
// PUT /api/v1/albums/:uid/links/:link
func UpdateAlbumLink(router *gin.RouterGroup, conf *config.Config) {
router.PUT("/albums/:uid/links/:link", func(c *gin.Context) {
UpdateLink(c, conf)
})
}
// DELETE /api/v1/albums/:uid/links/:link
func DeleteAlbumLink(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/albums/:uid/links/:link", func(c *gin.Context) {
DeleteLink(c, conf)
})
}
// GET /api/v1/albums/:uid/links
func GetAlbumLinks(router *gin.RouterGroup, conf *config.Config) {
router.GET("/albums/:uid/links", func(c *gin.Context) {
m, err := query.AlbumByUID(c.Param("uid"))
if err != nil {
@ -46,71 +144,87 @@ func LinkAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
if link, err := newLink(c); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
} else {
entity.Db().Model(&m).Association("Links").Append(link)
}
event.Success("created album share link")
c.JSON(http.StatusOK, m)
c.JSON(http.StatusOK, m.Links())
})
}
// POST /api/v1/photos/:uid/link
func LinkPhoto(router *gin.RouterGroup, conf *config.Config) {
// POST /api/v1/photos/:uid/links
func CreatePhotoLink(router *gin.RouterGroup, conf *config.Config) {
router.POST("/photos/:uid/links", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
return
}
m, err := query.PhotoByUID(c.Param("uid"))
if err != nil {
if _, err := query.PhotoByUID(c.Param("uid")); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrPhotoNotFound)
return
}
if link, err := newLink(c); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
} else {
entity.Db().Model(&m).Association("Links").Append(link)
}
event.Success("created photo share link")
c.JSON(http.StatusOK, m)
CreateLink(c, conf)
})
}
// POST /api/v1/labels/:uid/link
func LinkLabel(router *gin.RouterGroup, conf *config.Config) {
router.POST("/labels/:uid/links", func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
// PUT /api/v1/photos/:uid/links/:link
func UpdatePhotoLink(router *gin.RouterGroup, conf *config.Config) {
router.PUT("/photos/:uid/links/:link", func(c *gin.Context) {
UpdateLink(c, conf)
})
}
// DELETE /api/v1/photos/:uid/links/:link
func DeletePhotoLink(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/photos/:uid/links/:link", func(c *gin.Context) {
DeleteLink(c, conf)
})
}
// GET /api/v1/photos/:uid/links
func GetPhotoLinks(router *gin.RouterGroup, conf *config.Config) {
router.GET("/photos/:uid/links", func(c *gin.Context) {
m, err := query.PhotoByUID(c.Param("uid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound)
return
}
m, err := query.LabelByUID(c.Param("uid"))
c.JSON(http.StatusOK, m.Links())
})
}
if err != nil {
// POST /api/v1/labels/:uid/links
func CreateLabelLink(router *gin.RouterGroup, conf *config.Config) {
router.POST("/labels/:uid/links", func(c *gin.Context) {
if _, err := query.LabelByUID(c.Param("uid")); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrLabelNotFound)
return
}
if link, err := newLink(c); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())})
return
} else {
entity.Db().Model(&m).Association("Links").Append(link)
}
event.Success("created label share link")
c.JSON(http.StatusOK, m)
CreateLink(c, conf)
})
}
// PUT /api/v1/labels/:uid/links/:link
func UpdateLabelLink(router *gin.RouterGroup, conf *config.Config) {
router.PUT("/labels/:uid/links/:link", func(c *gin.Context) {
UpdateLink(c, conf)
})
}
// DELETE /api/v1/labels/:uid/links/:link
func DeleteLabelLink(router *gin.RouterGroup, conf *config.Config) {
router.DELETE("/labels/:uid/links/:link", func(c *gin.Context) {
DeleteLink(c, conf)
})
}
// GET /api/v1/labels/:uid/links
func GetLabelLinks(router *gin.RouterGroup, conf *config.Config) {
router.GET("/labels/:uid/links", func(c *gin.Context) {
m, err := query.LabelByUID(c.Param("uid"))
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound)
return
}
c.JSON(http.StatusOK, m.Links())
})
}

View file

@ -14,56 +14,50 @@ func TestLinkAlbum(t *testing.T) {
t.Run("create share link", func(t *testing.T) {
app, router, ctx := NewApiTest()
var album entity.Album
var link entity.Link
LinkAlbum(router, ctx)
CreateAlbumLink(router, ctx)
result1 := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, result1.Code)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
if err := json.Unmarshal(result1.Body.Bytes(), &album); err != nil {
if resp.Code != http.StatusOK {
t.Fatal(resp.Body.String())
}
if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil {
t.Fatal(err)
}
if len(album.Links) != 1 {
t.Fatalf("one link expected: %d, %+v", len(album.Links), album)
}
link := album.Links[0]
assert.NotEmpty(t, link.LinkUID)
assert.NotEmpty(t, link.ShareUID)
assert.NotEmpty(t, link.ShareToken)
assert.Equal(t, true, link.CanEdit)
assert.Nil(t, link.LinkExpires)
assert.Equal(t, 0, link.ShareExpires)
assert.False(t, link.CanComment)
assert.True(t, link.CanEdit)
result2 := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "", "Expires": 3600}`)
assert.Equal(t, http.StatusOK, result2.Code)
// t.Logf("result1: %s", result1.Body.String())
// t.Logf("result2: %s", result2.Body.String())
if err := json.Unmarshal(result2.Body.Bytes(), &album); err != nil {
t.Fatal(err)
}
if len(album.Links) != 2 {
t.Fatal("two links expected")
}
})
t.Run("album not found", func(t *testing.T) {
t.Run("album does not exist", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkAlbum(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/albums/xxx/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusNotFound, r.Code)
val := gjson.Get(r.Body.String(), "error")
CreateAlbumLink(router, ctx)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/xxx/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
if resp.Code != http.StatusNotFound {
t.Fatal(resp.Body.String())
}
val := gjson.Get(resp.Body.String(), "error")
assert.Equal(t, "Album not found", val.String())
})
t.Run("invalid request", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkAlbum(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"xxx": 123, "Expires": 0, "CanEdit": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
CreateAlbumLink(router, ctx)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "ShareExpires": "abc", "CanEdit": true}`)
if resp.Code != http.StatusBadRequest {
t.Fatal(resp.Body.String())
}
})
}
@ -71,52 +65,39 @@ func TestLinkPhoto(t *testing.T) {
t.Run("create share link", func(t *testing.T) {
app, router, ctx := NewApiTest()
var photo entity.Photo
var link entity.Link
LinkPhoto(router, ctx)
CreatePhotoLink(router, ctx)
result1 := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, result1.Code)
resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, resp.Code)
if err := json.Unmarshal(result1.Body.Bytes(), &photo); err != nil {
if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil {
t.Fatal(err)
}
if len(photo.Links) != 1 {
t.Fatalf("one link expected: %d, %+v", len(photo.Links), photo)
}
link := photo.Links[0]
assert.Equal(t, "foobar", link.LinkPassword)
assert.Nil(t, link.LinkExpires)
assert.NotEmpty(t, link.LinkUID)
assert.NotEmpty(t, link.ShareUID)
assert.NotEmpty(t, link.ShareToken)
assert.Equal(t, 0, link.ShareExpires)
assert.False(t, link.CanComment)
assert.True(t, link.CanEdit)
result2 := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password": "", "Expires": 3600}`)
assert.Equal(t, http.StatusOK, result2.Code)
if err := json.Unmarshal(result2.Body.Bytes(), &photo); err != nil {
t.Fatal(err)
}
if len(photo.Links) != 2 {
t.Fatal("two links expected")
}
})
t.Run("photo not found", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkPhoto(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/xxx/link", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusNotFound, r.Code)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "Photo not found", val.String())
CreatePhotoLink(router, ctx)
resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/xxx/link", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
if resp.Code != http.StatusNotFound {
t.Fatal(resp.Body.String())
}
})
t.Run("invalid request", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkPhoto(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"xxx": 123, "Expires": 0, "CanEdit": "xxx"}`)
CreatePhotoLink(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"xxx": 123, "ShareExpires": "abc", "CanEdit": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
@ -125,52 +106,37 @@ func TestLinkLabel(t *testing.T) {
t.Run("create share link", func(t *testing.T) {
app, router, ctx := NewApiTest()
var label entity.Label
var link entity.Link
LinkLabel(router, ctx)
CreateLabelLink(router, ctx)
result1 := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, result1.Code)
resp := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, resp.Code)
if err := json.Unmarshal(result1.Body.Bytes(), &label); err != nil {
if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil {
t.Fatal(err)
}
if len(label.Links) != 1 {
t.Fatalf("one link expected: %d, %+v", len(label.Links), label)
}
link := label.Links[0]
assert.Equal(t, "foobar", link.LinkPassword)
assert.Nil(t, link.LinkExpires)
assert.NotEmpty(t, link.LinkUID)
assert.NotEmpty(t, link.ShareUID)
assert.NotEmpty(t, link.ShareToken)
assert.Equal(t, 0, link.ShareExpires)
assert.False(t, link.CanComment)
assert.True(t, link.CanEdit)
result2 := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"Password": "", "Expires": 3600}`)
assert.Equal(t, http.StatusOK, result2.Code)
if err := json.Unmarshal(result2.Body.Bytes(), &label); err != nil {
t.Fatal(err)
}
if len(label.Links) != 2 {
t.Fatal("two links expected")
}
})
t.Run("label not found", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkLabel(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/labels/xxx/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusNotFound, r.Code)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "Label not found", val.String())
CreateLabelLink(router, ctx)
resp := PerformRequestWithBody(app, "POST", "/api/v1/labels/xxx/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
if resp.Code != http.StatusNotFound {
t.Fatal(resp.Body.String())
}
})
t.Run("invalid request", func(t *testing.T) {
app, router, ctx := NewApiTest()
LinkLabel(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"xxx": 123, "Expires": 0, "CanEdit": "xxx"}`)
CreateLabelLink(router, ctx)
r := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"xxx": 123, "ShareExpires": "abc", "CanEdit": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View file

@ -45,7 +45,6 @@ type Album struct {
AlbumMonth int `gorm:"index:idx_albums_country_year_month;" json:"Month" yaml:"Month,omitempty"`
AlbumFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
AlbumPrivate bool `json:"Private" yaml:"Private,omitempty"`
Links []Link `gorm:"foreignkey:share_uid;association_foreignkey:album_uid" json:"Links" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"-" yaml:"-"`
@ -365,3 +364,8 @@ func (m *Album) RemovePhotos(UIDs []string) (removed []PhotoAlbum) {
return removed
}
// Links returns all share links for this entity.
func (m *Album) Links() Links {
return FindLinks("", m.AlbumUID)
}

View file

@ -35,7 +35,6 @@ var AlbumFixtures = AlbumMap{
AlbumOrder: "oldest",
AlbumTemplate: "",
AlbumFavorite: false,
Links: []Link{},
CreatedAt: time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,
@ -52,7 +51,6 @@ var AlbumFixtures = AlbumMap{
AlbumOrder: "newest",
AlbumTemplate: "",
AlbumFavorite: true,
Links: []Link{},
CreatedAt: time.Date(2019, 7, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,
@ -69,7 +67,6 @@ var AlbumFixtures = AlbumMap{
AlbumOrder: "oldest",
AlbumTemplate: "",
AlbumFavorite: false,
Links: []Link{},
CreatedAt: time.Date(2019, 7, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,
@ -87,7 +84,6 @@ var AlbumFixtures = AlbumMap{
AlbumTemplate: "",
AlbumFilter: "path:\"1990/04\"",
AlbumFavorite: false,
Links: []Link{},
CreatedAt: time.Date(2019, 7, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,
@ -105,7 +101,6 @@ var AlbumFixtures = AlbumMap{
AlbumTemplate: "",
AlbumFilter: "",
AlbumFavorite: false,
Links: []Link{},
CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,

View file

@ -49,6 +49,7 @@ var Entities = Types{
"photos_labels": &PhotoLabel{},
"keywords": &Keyword{},
"photos_keywords": &PhotoKeyword{},
"passwords": &Password{},
"links": &Link{},
}

View file

@ -51,7 +51,6 @@ type File struct {
FileError string `gorm:"type:varbinary(512)" json:"Error" yaml:"Error,omitempty"`
Share []FileShare `json:"-" yaml:"-"`
Sync []FileSync `json:"-" yaml:"-"`
Links []Link `gorm:"foreignkey:share_uid;association_foreignkey:file_uid" json:"Links" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
CreatedIn int64 `json:"CreatedIn" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
@ -193,3 +192,8 @@ func (m *File) RelatedPhoto() *Photo {
func (m *File) NoJPEG() bool {
return m.FileType != string(fs.TypeJpeg)
}
// Links returns all share links for this entity.
func (m *File) Links() Links {
return FindLinks("", m.FileUID)
}

View file

@ -40,7 +40,6 @@ var FileFixtures = map[string]File{
FileShareFixtures.Get("FileShare2", 0, 0, ""),
},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
@ -79,7 +78,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2019, 3, 6, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
@ -118,7 +116,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2019, 3, 6, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
@ -157,7 +154,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2019, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
@ -196,7 +192,6 @@ var FileFixtures = map[string]File{
FileError: "Error",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2018, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2029, 3, 28, 14, 6, 0, 0, time.UTC),
@ -235,7 +230,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2018, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2029, 3, 28, 14, 6, 0, 0, time.UTC),
@ -274,7 +268,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2018, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2029, 3, 28, 14, 6, 0, 0, time.UTC),
@ -313,7 +306,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2018, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2029, 3, 28, 14, 6, 0, 0, time.UTC),
@ -352,7 +344,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2018, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2029, 3, 28, 14, 6, 0, 0, time.UTC),
@ -391,7 +382,6 @@ var FileFixtures = map[string]File{
FileError: "Error",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2018, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2029, 3, 28, 14, 6, 0, 0, time.UTC),
@ -430,7 +420,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2019, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
@ -469,7 +458,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2019, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
@ -508,7 +496,6 @@ var FileFixtures = map[string]File{
FileError: "",
Share: []FileShare{},
Sync: []FileSync{},
Links: []Link{},
CreatedAt: time.Date(2019, 1, 1, 2, 6, 51, 0, time.UTC),
CreatedIn: 2,
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),

View file

@ -35,7 +35,6 @@ type Folder struct {
FolderIgnore bool `json:"Ignore" yaml:"Ignore,omitempty"`
FolderWatch bool `json:"Watch" yaml:"Watch,omitempty"`
FileCount int `gorm:"-" json:"FileCount" yaml:"-"`
Links []Link `gorm:"foreignkey:share_uid;association_foreignkey:folder_uid" json:"Links" yaml:"-"`
CreatedAt time.Time `json:"-" yaml:"-"`
UpdatedAt time.Time `json:"-" yaml:"-"`
ModifiedAt *time.Time `json:"ModifiedAt,omitempty" yaml:"-"`

View file

@ -25,7 +25,6 @@ type Label struct {
LabelDescription string `gorm:"type:text;" json:"Description" yaml:"Description,omitempty"`
LabelNotes string `gorm:"type:text;" json:"Notes" yaml:"Notes,omitempty"`
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id" json:"-" yaml:"-"`
Links []Link `gorm:"foreignkey:share_uid;association_foreignkey:label_uid" json:"Links" yaml:"-"`
PhotoCount int `gorm:"default:1" json:"PhotoCount" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
@ -201,3 +200,8 @@ func (m *Label) UpdateClassify(label classify.Label) error {
return nil
}
// Links returns all share links for this entity.
func (m *Label) Links() Links {
return FindLinks("", m.LabelUID)
}

View file

@ -44,7 +44,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -62,7 +61,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 2,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -80,7 +78,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 3,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -98,7 +95,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 4,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -116,7 +112,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 5,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -134,7 +129,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -152,7 +146,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -170,7 +163,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 1,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,
@ -188,7 +180,6 @@ var LabelFixtures = LabelMap{
LabelNotes: "",
PhotoCount: 4,
LabelCategories: []*Label{},
Links: []Link{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
DeletedAt: nil,

View file

@ -1,42 +1,148 @@
package entity
import (
"fmt"
"time"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/rnd"
)
type Links []Link
// Link represents a sharing link.
type Link struct {
ShareUID string `gorm:"type:varbinary(42);primary_key;" json:"UID"`
LinkToken string `gorm:"type:varbinary(255);primary_key;" json:"Token"`
LinkPassword string `gorm:"type:varbinary(255);" json:"Password"`
LinkExpires *time.Time `gorm:"type:datetime;" json:"Expires"`
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"`
ShareToken string `gorm:"type:varbinary(255);unique_index:idx_links_uid_token;" json:"ShareToken"`
ShareExpires int `json:"ShareExpires"`
HasPassword bool `json:"HasPassword"`
CanComment bool `json:"CanComment"`
CanEdit bool `json:"CanEdit"`
CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt"`
UpdatedAt time.Time `deepcopier:"skip" json:"UpdatedAt"`
DeletedAt *time.Time `deepcopier:"skip" sql:"index" json:"DeletedAt,omitempty"`
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Link) BeforeCreate(scope *gorm.Scope) error {
if err := scope.SetColumn("LinkToken", rnd.Token(10)); err != nil {
return err
if rnd.IsUID(m.LinkUID, 's') {
return nil
}
return nil
return scope.SetColumn("LinkUID", rnd.PPID('s'))
}
// NewLink creates a sharing link.
func NewLink(password string, canComment, canEdit bool) Link {
func NewLink(shareUID string, canComment, canEdit bool) Link {
result := Link{
LinkToken: rnd.Token(10),
LinkPassword: password,
LinkUID: rnd.PPID('s'),
ShareUID: shareUID,
ShareToken: rnd.Token(10),
CanComment: canComment,
CanEdit: canEdit,
}
return result
}
func (m *Link) SetPassword(password string) error {
pw := NewPassword(m.LinkUID, password)
if err := pw.Save(); err != nil {
return err
}
m.HasPassword = true
return nil
}
func (m *Link) InvalidPassword(password string) bool {
if !m.HasPassword {
return false
}
pw := FindPassword(m.LinkUID)
if pw == nil {
return password != ""
}
return pw.InvalidPassword(password)
}
// Create inserts a new row to the database.
func (m *Link) Create() error {
if !rnd.IsPPID(m.ShareUID, 0) {
return fmt.Errorf("link: invalid share uid (%s)", m.ShareUID)
}
if m.ShareToken == "" {
return fmt.Errorf("link: empty share token")
}
return Db().Create(m).Error
}
// Save inserts a new row to the database or updates a row if the primary key already exists.
func (m *Link) Save() error {
if !rnd.IsPPID(m.ShareUID, 0) {
return fmt.Errorf("link: invalid share uid (%s)", m.ShareUID)
}
if m.ShareToken == "" {
return fmt.Errorf("link: empty share token")
}
return Db().Save(m).Error
}
func (m *Link) Delete() error {
if m.ShareToken == "" {
return fmt.Errorf("link: empty share token")
}
return Db().Delete(m).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)
}
return nil
}
// FindLinks returns a slice of links for a token and share UID (at least one must be provided).
func FindLinks(shareToken, shareUID string) (result []Link) {
if shareToken == "" && shareUID == "" {
log.Errorf("link: share token and uid must not be empty at the same time (find links)")
return []Link{}
}
q := Db()
if shareToken != "" {
q = q.Where("share_token = ?", shareToken)
}
if shareUID != "" {
q = q.Where("share_uid = ?", shareUID)
}
if err := q.Find(&result).Error; err != nil {
log.Errorf("link: %s (not found)", err)
}
return result
}
// String returns an human readable identifier for logging.
func (m *Link) String() string {
return fmt.Sprintf("%s/%s", m.ShareUID, m.ShareToken)
}

View file

@ -8,15 +8,13 @@ type LinkMap map[string]Link
var LinkFixtures = LinkMap{
"1jxf3jfn2k": {
LinkToken: "1jxf3jfn2k",
LinkPassword: "somepassword",
LinkExpires: &date,
ShareUID: "4",
ShareToken: "1jxf3jfn2k",
ShareExpires: 0,
ShareUID: "st9lxuqxpogaaba7",
CanComment: true,
CanEdit: false,
CreatedAt: time.Date(2020, 3, 6, 2, 6, 51, 0, time.UTC),
UpdatedAt: time.Date(2020, 3, 28, 14, 6, 0, 0, time.UTC),
DeletedAt: nil,
},
}

View file

@ -6,9 +6,10 @@ import (
)
func TestNewLink(t *testing.T) {
link := NewLink("passwd12", true, false)
assert.Equal(t, "passwd12", link.LinkPassword)
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, 10, len(link.ShareToken))
assert.Equal(t, 16, len(link.LinkUID))
}

View file

@ -0,0 +1,82 @@
package entity
import (
"time"
"golang.org/x/crypto/bcrypt"
)
// Password represents a password hash.
type Password struct {
UID string `gorm:"type:varbinary(255);primary_key;" json:"UID"`
Hash string `deepcopier:"skip" gorm:"type:varbinary(255);" json:"Hash"`
CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt"`
UpdatedAt time.Time `deepcopier:"skip" json:"UpdatedAt"`
}
func NewPassword(uid, password string) Password {
if uid == "" {
panic("password: uid must not be empty")
}
m := Password{UID: uid}
if password != "" {
if err := m.SetPassword(password); err != nil {
log.Errorf("password: %s (set password)", err)
}
}
return m
}
func (m *Password) SetPassword(password string) error {
if bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14); err != nil {
return err
} else {
m.Hash = string(bytes)
return nil
}
}
func (m *Password) InvalidPassword(password string) bool {
if m.Hash == "" && password == "" {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(m.Hash), []byte(password))
return err != nil
}
// Create inserts a new row to the database.
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.
func (m *Password) Save() error {
return Db().Save(m).Error
}
// FindPassword returns an entity pointer if exists.
func FindPassword(uid string) *Password {
result := Password{}
if err := Db().Where("uid = ?", uid).First(&result).Error; err == nil {
return &result
} else {
log.Errorf("password: %s (not found)", err)
}
return nil
}
// String returns the password hash.
func (m *Password) String() string {
return m.Hash
}
// Unknown returns true if the password is an empty string.
func (m *Password) Unknown() bool {
return m.Hash == ""
}

View file

@ -75,7 +75,6 @@ type Photo struct {
Lens *Lens `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Lens" yaml:"-"`
Location *Location `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Location" yaml:"-"`
Place *Place `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Place" yaml:"-"`
Links []Link `gorm:"foreignkey:share_uid;association_foreignkey:photo_uid" json:"Links" yaml:"-"`
Keywords []Keyword `json:"-" yaml:"-"`
Albums []Album `json:"-" yaml:"-"`
Files []File `yaml:"-"`
@ -871,3 +870,8 @@ func (m *Photo) Approve() error {
return nil
}
// Links returns all share links for this entity.
func (m *Photo) Links() Links {
return FindLinks("", m.PhotoUID)
}

View file

@ -65,7 +65,6 @@ var PhotoFixtures = PhotoMap{
PlaceID: UnknownPlace.ID,
Place: &UnknownPlace,
PhotoCountry: UnknownPlace.CountryCode(),
Links: []Link{},
Keywords: []Keyword{
KeywordFixtures.Get("bridge"),
},
@ -121,7 +120,6 @@ var PhotoFixtures = PhotoMap{
PhotoMonth: 2,
Details: DetailsFixtures.Get("lake", 1000001),
DescriptionSrc: "",
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -171,7 +169,6 @@ var PhotoFixtures = PhotoMap{
LocationID: UnknownLocation.ID,
Place: &UnknownPlace,
PlaceID: UnknownPlace.ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -220,7 +217,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -273,7 +269,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{
KeywordFixtures.Get("bridge"),
},
@ -326,7 +321,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -375,7 +369,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -424,7 +417,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -473,7 +465,6 @@ var PhotoFixtures = PhotoMap{
LocationSrc: "manual",
Place: PlaceFixtures.Pointer("mexico"),
PlaceID: PlaceFixtures.Pointer("mexico").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -522,7 +513,6 @@ var PhotoFixtures = PhotoMap{
LocationSrc: "",
Place: PlaceFixtures.Pointer("mexico"),
PlaceID: PlaceFixtures.Pointer("mexico").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -571,7 +561,6 @@ var PhotoFixtures = PhotoMap{
LocationSrc: "",
Place: PlaceFixtures.Pointer("holidaypark"),
PlaceID: PlaceFixtures.Pointer("holidaypark").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -620,7 +609,6 @@ var PhotoFixtures = PhotoMap{
LocationSrc: "",
Place: PlaceFixtures.Pointer("emptyNameLongCity"),
PlaceID: PlaceFixtures.Pointer("emptyNameLongCity").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -669,7 +657,6 @@ var PhotoFixtures = PhotoMap{
PhotoMonth: 7,
Details: Details{},
DescriptionSrc: "",
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -718,7 +705,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -767,7 +753,6 @@ var PhotoFixtures = PhotoMap{
LocationSrc: "",
Place: PlaceFixtures.Pointer("mediumLongLocName"),
PlaceID: PlaceFixtures.Pointer("mediumLongLocName").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -817,7 +802,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -866,7 +850,6 @@ var PhotoFixtures = PhotoMap{
PhotoMonth: 0,
Details: DetailsFixtures.Get("lake", 1000015),
DescriptionSrc: "location",
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -915,7 +898,6 @@ var PhotoFixtures = PhotoMap{
PhotoMonth: 0,
Details: DetailsFixtures.Get("lake", 1000015),
DescriptionSrc: "location",
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -966,7 +948,6 @@ var PhotoFixtures = PhotoMap{
PhotoMonth: 0,
Details: DetailsFixtures.Get("lake", 1000015),
DescriptionSrc: "location",
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},
@ -1017,7 +998,6 @@ var PhotoFixtures = PhotoMap{
CameraID: CameraFixtures.Pointer("canon-eos-6d").ID,
Lens: LensFixtures.Pointer("lens-f-380"),
LensID: LensFixtures.Pointer("lens-f-380").ID,
Links: []Link{},
Keywords: []Keyword{},
Albums: []Album{},
Files: []File{},

View file

@ -1,10 +1,10 @@
package form
// Link represents a sharing link form.
// Link represents a link sharing form.
type Link struct {
Token string `json:"Token"`
Password string `json:"Password"`
Expires int `json:"Expires"`
ShareToken string `json:"ShareToken"`
ShareExpires int `json:"ShareExpires"`
CanComment bool `json:"CanComment"`
CanEdit bool `json:"CanEdit"`
}

View file

@ -40,7 +40,7 @@ type AlbumResult struct {
// AlbumByUID returns a Album based on the UID.
func AlbumByUID(albumUID string) (album entity.Album, err error) {
if err := Db().Where("album_uid = ?", albumUID).Preload("Links").First(&album).Error; err != nil {
if err := Db().Where("album_uid = ?", albumUID).First(&album).Error; err != nil {
return album, err
}
@ -108,7 +108,7 @@ func AlbumSearch(f form.AlbumSearch) (results []AlbumResult, err error) {
s = s.Table("albums").
Select(`albums.*,
COUNT(photos_albums.album_uid) AS photo_count,
COUNT(links.link_token) AS link_count`).
COUNT(links.share_token) AS link_count`).
Joins("LEFT JOIN photos_albums ON photos_albums.album_uid = albums.album_uid").
Joins("LEFT JOIN links ON links.share_uid = albums.album_uid").
Where("albums.deleted_at IS NULL").

View file

@ -55,7 +55,7 @@ func FilesByUID(u []string, limit int, offset int) (files entity.Files, err erro
// FileByPhotoUID
func FileByPhotoUID(u string) (file entity.File, err error) {
if err := Db().Where("photo_uid = ? AND file_primary = 1", u).Preload("Links").Preload("Photo").First(&file).Error; err != nil {
if err := Db().Where("photo_uid = ? AND file_primary = 1", u).Preload("Photo").First(&file).Error; err != nil {
return file, err
}
@ -64,7 +64,7 @@ func FileByPhotoUID(u string) (file entity.File, err error) {
// VideoByPhotoUID
func VideoByPhotoUID(u string) (file entity.File, err error) {
if err := Db().Where("photo_uid = ? AND file_video = 1", u).Preload("Links").Preload("Photo").First(&file).Error; err != nil {
if err := Db().Where("photo_uid = ? AND file_video = 1", u).Preload("Photo").First(&file).Error; err != nil {
return file, err
}
@ -73,7 +73,7 @@ func VideoByPhotoUID(u string) (file entity.File, err error) {
// FileByUID returns the file entity for a given UID.
func FileByUID(uid string) (file entity.File, err error) {
if err := Db().Where("file_uid = ?", uid).Preload("Links").Preload("Photo").First(&file).Error; err != nil {
if err := Db().Where("file_uid = ?", uid).Preload("Photo").First(&file).Error; err != nil {
return file, err
}
@ -82,7 +82,7 @@ func FileByUID(uid string) (file entity.File, err error) {
// FileByHash finds a file with a given hash string.
func FileByHash(fileHash string) (file entity.File, err error) {
if err := Db().Where("file_hash = ?", fileHash).Preload("Links").Preload("Photo").First(&file).Error; err != nil {
if err := Db().Where("file_hash = ?", fileHash).Preload("Photo").First(&file).Error; err != nil {
return file, err
}

View file

@ -15,7 +15,7 @@ func PhotoLabel(photoID, labelID uint) (label entity.PhotoLabel, err error) {
// LabelBySlug returns a Label based on the slug name.
func LabelBySlug(labelSlug string) (label entity.Label, err error) {
if err := Db().Where("label_slug = ? OR custom_slug = ?", labelSlug, labelSlug).Preload("Links").First(&label).Error; err != nil {
if err := Db().Where("label_slug = ? OR custom_slug = ?", labelSlug, labelSlug).First(&label).Error; err != nil {
return label, err
}
@ -24,7 +24,7 @@ func LabelBySlug(labelSlug string) (label entity.Label, err error) {
// LabelByUID returns a Label based on the label UID.
func LabelByUID(labelUID string) (label entity.Label, err error) {
if err := Db().Where("label_uid = ?", labelUID).Preload("Links").First(&label).Error; err != nil {
if err := Db().Where("label_uid = ?", labelUID).First(&label).Error; err != nil {
return label, err
}

View file

@ -14,7 +14,6 @@ func PhotoByID(photoID uint64) (photo entity.Photo, err error) {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
Preload("Labels.Label").
Preload("Links").
Preload("Camera").
Preload("Lens").
Preload("Details").
@ -35,7 +34,6 @@ func PhotoByUID(photoUID string) (photo entity.Photo, err error) {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
Preload("Labels.Label").
Preload("Links").
Preload("Camera").
Preload("Lens").
Preload("Details").
@ -56,7 +54,6 @@ func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
Preload("Labels.Label").
Preload("Links").
Preload("Camera").
Preload("Lens").
Preload("Details").
@ -100,7 +97,6 @@ func PhotosMaintenance(limit int, offset int) (entities entity.Photos, err error
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
Preload("Labels.Label").
Preload("Links").
Preload("Camera").
Preload("Lens").
Preload("Details").

View file

@ -43,7 +43,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.UpdatePhoto(v1, conf)
api.GetPhotos(v1, conf)
api.GetPhotoDownload(v1, conf)
api.LinkPhoto(v1, conf)
api.GetPhotoLinks(v1, conf)
api.CreatePhotoLink(v1, conf)
api.UpdatePhotoLink(v1, conf)
api.DeletePhotoLink(v1, conf)
api.ApprovePhoto(v1, conf)
api.LikePhoto(v1, conf)
api.DislikePhoto(v1, conf)
@ -52,12 +55,14 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.UpdatePhotoLabel(v1, conf)
api.GetMomentsTime(v1, conf)
api.GetFile(v1, conf)
api.LinkFile(v1, conf)
api.SetPhotoPrimary(v1, conf)
api.GetLabels(v1, conf)
api.UpdateLabel(v1, conf)
api.LinkLabel(v1, conf)
api.GetLabelLinks(v1, conf)
api.CreateLabelLink(v1, conf)
api.UpdateLabelLink(v1, conf)
api.DeleteLabelLink(v1, conf)
api.LikeLabel(v1, conf)
api.DislikeLabel(v1, conf)
api.LabelThumbnail(v1, conf)
@ -83,7 +88,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.DeleteAlbum(v1, conf)
api.DownloadAlbum(v1, conf)
api.GetAlbums(v1, conf)
api.LinkAlbum(v1, conf)
api.GetAlbumLinks(v1, conf)
api.CreateAlbumLink(v1, conf)
api.UpdateAlbumLink(v1, conf)
api.DeleteAlbumLink(v1, conf)
api.LikeAlbum(v1, conf)
api.DislikeAlbum(v1, conf)
api.AlbumThumbnail(v1, conf)

View file

@ -21,7 +21,11 @@ func IsPPID(s string, prefix byte) bool {
return false
}
return s[0] == prefix && IsLowerAlnum(s)
if !IsLowerAlnum(s) {
return false
}
return prefix == 0 || s[0] == prefix
}
// IsHex returns true if the string only contains hex numbers, dashes and letters without whitespace.