media security_type updates

This commit is contained in:
2023-09-29 07:19:33 +10:00
parent d9c0c8f1d8
commit 42f2baca5e
12 changed files with 219 additions and 96 deletions

View File

@@ -69,10 +69,12 @@ class MediaConductor extends Conductor
{
$user = auth()->user();
if ($user === null) {
$builder->where('security_type', '');
$builder->where('security_type', '')
->orWhere('security_type', 'password');
} else {
$builder->where(function ($query) use ($user) {
$query->where('security_type', '')
->orWhere('security_type', 'password')
->orWhere(function ($subquery) use ($user) {
$subquery->where('security_type', 'permission')
->whereIn('security_data', $user->permissions);
@@ -89,12 +91,14 @@ class MediaConductor extends Conductor
*/
public static function viewable(Model $model): bool
{
if ($model->permission !== '') {
if (strcasecmp('permission', $model->security_type) === 0) {
/** @var \App\Models\User */
$user = auth()->user();
if ($user === null || $user->hasPermission($model->permission) === false) {
if ($user === null || $user->hasPermission($model->security_data) === false) {
return false;
}
} else if($model->security_type !== '' && strcasecmp('password', $model->security_type) !== 0) {
return false;
}
return true;

View File

@@ -170,6 +170,13 @@ class MediaController extends ApiController
if($data['security']['type'] === '') {
$data['security']['data'] = '';
}
if(strcasecmp($data['security']['type'], $medium->security_type) !== 0) {
if($request->has('storage') === false) {
$mime_type = $request->get('mime_type', $medium->mime_type);
$data['storage'] = Media::recommendedStorage($mime_type, $data['security']['type']);
}
}
}
if(array_key_exists('storage', $data) === true &&
@@ -288,16 +295,16 @@ class MediaController extends ApiController
* @param \App\Models\Media $medium Specified media.
* @return \Illuminate\Http\Response
*/
public function download(Request $request, Media $medium)
public function download(Request $request, Media $media)
{
$headers = [];
/* Check file exists */
if(Storage::disk($medium->storage)->exists($medium->name) === true) {
if(Storage::disk($media->storage)->exists($media->name) === false) {
return $this->respondNotFound();
}
$updated_at = Carbon::parse(Storage::disk($medium->storage)->lastModified($medium->name));
$updated_at = Carbon::parse(Storage::disk($media->storage)->lastModified($media->name));
$headerPragma = 'no-cache';
$headerCacheControl = 'max-age=0, must-revalidate';
@@ -316,21 +323,21 @@ class MediaController extends ApiController
}
}
if ($medium->security_type === '') {
if ($media->security_type === '') {
/* no security */
$headerPragma = 'public';
$headerExpires = $updated_at->addMonth()->toRfc2822String();
} else if (strcasecmp('password', $medium->security_type) === 0) {
} else if (strcasecmp('password', $media->security_type) === 0) {
/* password */
if(
($user === null || $user->hasPermission('admin/media') === false) &&
($request->has('password') === false || $request->get('password') !== $medium->security_data)) {
($request->has('password') === false || $request->get('password') !== $media->security_data)) {
return $this->respondForbidden();
}
} else if (strcasecmp('permission', $medium->security_type) === 0) {
} else if (strcasecmp('permission', $media->security_type) === 0) {
/* permission */
if(
$user === null || ($user->hasPermission('admin/media') === false && $user->hasPermission($medium->security_data) === false)) {
$user === null || ($user->hasPermission('admin/media') === false && $user->hasPermission($media->security_data) === false)) {
return $this->respondForbidden();
}
}//end if
@@ -341,7 +348,7 @@ class MediaController extends ApiController
$headers = [
'Cache-Control' => $headerCacheControl,
'Content-Disposition' => sprintf('inline; filename="%s"', basename($medium->name)),
'Content-Disposition' => sprintf('inline; filename="%s"', basename($media->name)),
'Etag' => $headerEtag,
'Expires' => $headerExpires,
'Last-Modified' => $headerLastModified,
@@ -360,15 +367,16 @@ class MediaController extends ApiController
return response()->make('', 304, $headers);
}
$headers['Content-Type'] = Storage::disk($medium->storage)->mimeType($medium->name);
$headers['Content-Length'] = Storage::disk($medium->storage)->size($medium->name);
$headers['Content-Disposition'] = 'inline; filename="' . basename($medium->name) . '"';
$headers['Content-Type'] = Storage::disk($media->storage)->mimeType($media->name);
$headers['Content-Length'] = Storage::disk($media->storage)->size($media->name);
$headers['Content-Disposition'] = 'attachment; filename="' . basename($media->name) . '"';
$stream = Storage::disk($medium->storage)->readStream($medium->name);
$stream = Storage::disk($media->storage)->readStream($media->name);
return response()->stream(
function () use ($stream) {
fclose($stream);
},
function() use($stream) {
while(ob_get_level() > 0) ob_end_flush();
fpassthru($stream);
},
200,
$headers
);

View File

@@ -327,7 +327,7 @@ class Media extends Model
public function getUrlPath(): string
{
$url = config("filesystems.disks.$this->storage.url");
return "$url/";
return "$url";
}
/**
@@ -985,11 +985,28 @@ class Media extends Model
return $this->hasMany(MediaJob::class, 'media_id');
}
public static function recommendedStorage(string $mime_type, string $security_type): string {
if($mime_type === '') {
return 'cdn';
}
if($security_type === '') {
if (strpos($mime_type, 'image/') === 0) {
return('local');
} else {
return('cdn');
}
}
return('private');
}
public static function verifyStorage($mime_type, $security_type, &$storage): int {
if($mime_type === '') {
return Media::STORAGE_MIME_MISSING;
}
Log::info('verify: ' . $storage);
if($storage === '') {
if($security_type === '') {
if (strpos($mime_type, 'image/') === 0) {

View File

@@ -85,6 +85,7 @@ import { Media } from "../helpers/api.types";
import { onMounted, ref, watch } from "vue";
import { ImportMetaExtras } from "../../../import-meta";
import { strCaseCmp } from "../helpers/string";
import { mediaGetWebURL } from "../helpers/media";
const emits = defineEmits(["update:modelValue"]);
const props = defineProps({
@@ -157,24 +158,8 @@ const updateFileList = (newFileList: Array<Media>) => {
fileList.value = [];
for (const mediaItem of newFileList) {
const webUrl = (import.meta as ImportMetaExtras).env.APP_URL;
const apiUrl = (import.meta as ImportMetaExtras).env.APP_URL_API;
// Is the URL a API request?
if (mediaItem.url.startsWith(apiUrl)) {
const fileUrlPath = mediaItem.url.substring(apiUrl.length);
const fileUrlParts = fileUrlPath.split("/");
if (
fileUrlParts.length === 4 &&
fileUrlParts[0].length === 0 &&
strCaseCmp("media", fileUrlParts[1]) === true &&
strCaseCmp("download", fileUrlParts[3]) === true
) {
mediaItem.url = webUrl + "/file/" + fileUrlParts[2];
fileList.value.push(mediaItem);
}
} else {
mediaItem.url = mediaGetWebURL(mediaItem);
if (mediaItem.url != "") {
fileList.value.push(mediaItem);
}
}

View File

@@ -1,6 +1,6 @@
<template>
<SMForm :model-value="form" @submit="handleSubmit">
<SMFormCard :loading="dialogLoading">
<SMCard :loading="dialogLoading">
<template #header>
<h3>Change Password</h3>
<p>Enter your new password below</p>
@@ -16,7 +16,7 @@
<button type="button" @click="handleClickCancel">Cancel</button>
<input role="button" type="submit" value="Update" />
</template>
</SMFormCard>
</SMCard>
</SMForm>
</template>
@@ -31,11 +31,12 @@ import { useToastStore } from "../../store/ToastStore";
import { useUserStore } from "../../store/UserStore";
import SMForm from "../SMForm.vue";
import SMInput from "../SMInput.vue";
import SMCard from "../SMCard.vue";
const form: FormObject = reactive(
Form({
password: FormControl("", And([Required(), Password()])),
})
}),
);
const applicationStore = useApplicationStore();

View File

@@ -145,7 +145,8 @@
)}')`,
}">
<div
v-if="item.security_type != ''">
v-if="item.security_type != ''"
class="absolute right--1 top--1 h-4 w-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">

View File

@@ -1,5 +1,6 @@
import { ImportMetaExtras } from "../../../import-meta";
import { Media, MediaJob } from "./api.types";
import { toTitleCase } from "./string";
import { strCaseCmp, toTitleCase } from "./string";
export const mediaGetVariantUrl = (
media: Media,
@@ -25,6 +26,30 @@ export const mediaGetVariantUrl = (
: media.url;
};
export const mediaGetWebURL = (media: Media): string => {
const webUrl = (import.meta as ImportMetaExtras).env.APP_URL;
const apiUrl = (import.meta as ImportMetaExtras).env.APP_URL_API;
let url = media.url;
// Is the URL a API request?
if (media.url.startsWith(apiUrl)) {
const fileUrlPath = media.url.substring(apiUrl.length);
const fileUrlParts = fileUrlPath.split("/");
if (
fileUrlParts.length >= 4 &&
fileUrlParts[0].length === 0 &&
strCaseCmp("media", fileUrlParts[1]) === true &&
strCaseCmp("download", fileUrlParts[3]) === true
) {
url = webUrl + "/file/" + fileUrlParts[2];
}
}
return url;
};
/**
* Check if a mime matches.
* @param {string} mimeExpected The mime expected.

View File

@@ -121,5 +121,9 @@ export const toPrice = (numOrString: number | string): string => {
* @returns {boolean} If the strings match.
*/
export const strCaseCmp = (string1: string, string2: string): boolean => {
return string1.toLowerCase() === string2.toLowerCase();
if (string1 !== undefined && string2 !== undefined) {
return string1.toLowerCase() === string2.toLowerCase();
}
return false;
};

View File

@@ -126,3 +126,22 @@ export const extractFileNameFromUrl = (url: string): string => {
const fileName = matches[1];
return fileName;
};
export const addQueryParam = (
url: string,
name: string,
value: string,
): string => {
const urlObject = new URL(url);
const queryParams = new URLSearchParams(urlObject.search);
if (queryParams.has(name)) {
queryParams.set(name, value);
} else {
// Add the new query parameter
queryParams.append(name, value);
}
urlObject.search = queryParams.toString();
return urlObject.toString();
};

View File

@@ -4,25 +4,49 @@
:status="pageStatus" />
<SMLoading v-else-if="pageLoading == true"></SMLoading>
<SMForm
v-else-if="showPasswordForm == true"
v-else-if="showForm == 'password'"
:model-value="form"
@submit="handleSubmit">
<SMFormCard>
<template #header>
<h3>Password Required</h3>
<p>This file requires a password before it can be viewed</p>
</template>
<template #body>
<SMInput
control="password"
type="password"
label="File Password"
autofocus />
</template>
<template #footer-space-between>
<input role="button" type="submit" value="OK" />
</template>
</SMFormCard>
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<h3 class="mb-4">Password Required</h3>
<p class="mb-2">
This file requires a password before it can be viewed
</p>
<SMInput
class="mb-4"
control="password"
type="password"
label="File Password"
autofocus />
<div class="flex flex-justify-end">
<input
type="submit"
class="font-medium px-6 py-3.1 rounded-2 hover:shadow-md text-lg transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
value="Submit" />
</div>
</div>
</SMForm>
<SMForm
v-else-if="showForm == 'complete'"
:model-value="form"
@submit="handleSubmit">
<div
class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<h3 class="mb-4">Download Complete</h3>
<p class="mb-2">
If you have permission to view this document, your download
should now begin.
</p>
<div class="flex flex-justify-end">
<button
role="button"
class="font-medium px-6 py-3.1 rounded-2 hover:shadow-md text-lg transition bg-sky-600 hover:bg-sky-500 text-white cursor-pointer"
@click="handleClose()">
Close
</button>
</div>
</div>
</SMForm>
</template>
@@ -30,8 +54,11 @@
import { reactive, ref } from "vue";
import { api } from "../helpers/api";
import { useRoute } from "vue-router";
import { Media } from "../helpers/api.types";
import { Media, MediaResponse } from "../helpers/api.types";
import SMForm from "../components/SMForm.vue";
import SMInput from "../components/SMInput.vue";
import SMLoading from "../components/SMLoading.vue";
import SMPageStatus from "../components/SMPageStatus.vue";
import { strCaseCmp } from "../helpers/string";
import { useUserStore } from "../store/UserStore";
import { Form, FormControl, FormObject } from "../helpers/form";
@@ -39,7 +66,7 @@ import { Required } from "../helpers/validate";
const pageStatus = ref(200);
const pageLoading = ref(true);
const showPasswordForm = ref(false);
const showForm = ref("");
const fileUrl = ref("");
const userStore = useUserStore();
@@ -71,6 +98,9 @@ const downloadFile = (params = {}) => {
}
window.location.href = url;
window.setTimeout(() => {
showForm.value = "complete";
}, 1500);
};
/*
@@ -84,6 +114,10 @@ const handleSubmit = () => {
downloadFile(params);
};
const handleClose = () => {
window.close();
};
/**
* Handle page loading
*/
@@ -96,34 +130,45 @@ const handleLoad = async () => {
id: route.params.id,
};
let result = await api.get({
url: "/media/:id",
params: params,
});
try {
let result = await api.get({
url: "/media/{id}",
params: params,
});
if (result.status === 200) {
const medium = result.data as Media;
fileUrl.value = medium.url;
if (result.status === 200) {
const data = result.data as MediaResponse;
const medium = data.medium as Media;
fileUrl.value = medium.url;
if (medium.security_type === "") {
downloadFile();
} else if (
strCaseCmp("permission", medium.security_type) === true &&
userStore.id
) {
const params = {
token: userStore.token,
};
if (medium.security_type === "") {
downloadFile();
} else if (
strCaseCmp("permission", medium.security_type) === true &&
userStore.id
) {
const params = {
token: userStore.token,
};
downloadFile(params);
} else if (strCaseCmp("password", medium.security_type) === true) {
showPasswordForm.value = true;
downloadFile(params);
} else if (
strCaseCmp("password", medium.security_type) === true
) {
showForm.value = "password";
} else {
/* unknown security type */
pageStatus.value = 403;
}
pageLoading.value = false;
} else {
/* unknown security type */
pageStatus.value = 403;
pageStatus.value = result.status;
pageLoading.value = false;
}
} else {
pageStatus.value = result.status;
} catch (error) {
pageStatus.value = error.status;
pageLoading.value = false;
}
}
};

View File

@@ -74,12 +74,24 @@
<template #item-title="item">
<div class="flex gap-2">
<div
class="w-100 h-100 max-h-15 max-w-20 mr-2 bg-contain bg-no-repeat bg-center"
class="w-100 h-100 max-h-15 max-w-20 mr-2 bg-contain bg-no-repeat bg-center relative"
:style="{
backgroundImage: `url('${mediaGetThumbnail(
item,
)}')`,
}"></div>
}">
<div
v-if="item.security_type != ''"
class="absolute right--1 top--1 h-4 w-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<title>locked</title>
<path
d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6A2,2 0 0,1 4,20V10C4,8.89 4.9,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z" />
</svg>
</div>
</div>
<div class="flex flex-col flex-justify-center">
<span>{{ item.title }}</span>
<span class="small">({{ item.name }})</span>
@@ -101,11 +113,11 @@
fill="currentColor" />
</svg>
</button>
<button
type="button"
<a
:href="mediaGetWebURL(item)"
class="bg-transparent cursor-pointer hover:text-sky-5"
title="Download"
@click="handleDownload(item)">
target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
@@ -114,7 +126,7 @@
d="M220-160q-24 0-42-18t-18-42v-143h60v143h520v-143h60v143q0 24-18 42t-42 18H220Zm260-153L287-506l43-43 120 120v-371h60v371l120-120 43 43-193 193Z"
fill="currrentColor" />
</svg>
</button>
</a>
<button
type="button"
class="bg-transparent cursor-pointer hover:text-red-7"
@@ -170,11 +182,11 @@ import SMMastHead from "../../components/SMMastHead.vue";
import SMTable from "../../components/SMTable.vue";
import SMPagination from "../../components/SMPagination.vue";
import SMLoading from "../../components/SMLoading.vue";
import { updateRouterParams } from "../../helpers/url";
import { addQueryParam, updateRouterParams } from "../../helpers/url";
import { userHasPermission } from "../../helpers/utils";
import SMPageStatus from "../../components/SMPageStatus.vue";
import SMCheckbox from "../../components/SMCheckbox.vue";
import { mediaGetThumbnail } from "../../helpers/media";
import { mediaGetThumbnail, mediaGetWebURL } from "../../helpers/media";
const route = useRoute();
const router = useRouter();
@@ -448,7 +460,9 @@ const handleEditSelected = async () => {
* @param {Media} item The media item.
*/
const handleDownload = (item: Media) => {
window.open(`${item.url}?download=1`, "_blank");
// window.open(`${item.url}?download=1`, "_blank");
// window.open(addQueryParam(mediaGetWebURL(item), "download", "1"), "_blank");
window.open(mediaGetWebURL(item), "_blank");
};
const computedSelectedCount = computed(() => {

View File

@@ -44,7 +44,7 @@ Route::get('/users/{user}/events', [UserController::class, 'eventList']);
Route::get('media/jobs', [MediaJobController::class, 'index']);
Route::get('media/jobs/{mediaJob}', [MediaJobController::class, 'show']);
Route::apiResource('media', MediaController::class);
Route::get('media/{medium}/download', [MediaController::class, 'download']);
Route::get('media/{media}/download', [MediaController::class, 'download']);
Route::apiResource('articles', ArticleController::class);
// Route::apiAddendumResource('attachments', 'articles', ArticleController::class);