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

View File

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

View File

@@ -327,7 +327,7 @@ class Media extends Model
public function getUrlPath(): string public function getUrlPath(): string
{ {
$url = config("filesystems.disks.$this->storage.url"); $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'); 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 { public static function verifyStorage($mime_type, $security_type, &$storage): int {
if($mime_type === '') { if($mime_type === '') {
return Media::STORAGE_MIME_MISSING; return Media::STORAGE_MIME_MISSING;
} }
Log::info('verify: ' . $storage);
if($storage === '') { if($storage === '') {
if($security_type === '') { if($security_type === '') {
if (strpos($mime_type, 'image/') === 0) { 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 { onMounted, ref, watch } from "vue";
import { ImportMetaExtras } from "../../../import-meta"; import { ImportMetaExtras } from "../../../import-meta";
import { strCaseCmp } from "../helpers/string"; import { strCaseCmp } from "../helpers/string";
import { mediaGetWebURL } from "../helpers/media";
const emits = defineEmits(["update:modelValue"]); const emits = defineEmits(["update:modelValue"]);
const props = defineProps({ const props = defineProps({
@@ -157,24 +158,8 @@ const updateFileList = (newFileList: Array<Media>) => {
fileList.value = []; fileList.value = [];
for (const mediaItem of newFileList) { for (const mediaItem of newFileList) {
const webUrl = (import.meta as ImportMetaExtras).env.APP_URL; mediaItem.url = mediaGetWebURL(mediaItem);
const apiUrl = (import.meta as ImportMetaExtras).env.APP_URL_API; if (mediaItem.url != "") {
// 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 {
fileList.value.push(mediaItem); fileList.value.push(mediaItem);
} }
} }

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { ImportMetaExtras } from "../../../import-meta";
import { Media, MediaJob } from "./api.types"; import { Media, MediaJob } from "./api.types";
import { toTitleCase } from "./string"; import { strCaseCmp, toTitleCase } from "./string";
export const mediaGetVariantUrl = ( export const mediaGetVariantUrl = (
media: Media, media: Media,
@@ -25,6 +26,30 @@ export const mediaGetVariantUrl = (
: media.url; : 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. * Check if a mime matches.
* @param {string} mimeExpected The mime expected. * @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. * @returns {boolean} If the strings match.
*/ */
export const strCaseCmp = (string1: string, string2: string): boolean => { 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]; const fileName = matches[1];
return fileName; 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" /> :status="pageStatus" />
<SMLoading v-else-if="pageLoading == true"></SMLoading> <SMLoading v-else-if="pageLoading == true"></SMLoading>
<SMForm <SMForm
v-else-if="showPasswordForm == true" v-else-if="showForm == 'password'"
:model-value="form" :model-value="form"
@submit="handleSubmit"> @submit="handleSubmit">
<SMFormCard> <div
<template #header> class="max-w-2xl mx-auto border-1 bg-white rounded-xl mt-7xl text-gray-5 px-12 py-8">
<h3>Password Required</h3> <h3 class="mb-4">Password Required</h3>
<p>This file requires a password before it can be viewed</p> <p class="mb-2">
</template> This file requires a password before it can be viewed
<template #body> </p>
<SMInput <SMInput
control="password" class="mb-4"
type="password" control="password"
label="File Password" type="password"
autofocus /> label="File Password"
</template> autofocus />
<template #footer-space-between> <div class="flex flex-justify-end">
<input role="button" type="submit" value="OK" /> <input
</template> type="submit"
</SMFormCard> 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> </SMForm>
</template> </template>
@@ -30,8 +54,11 @@
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { useRoute } from "vue-router"; 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 SMLoading from "../components/SMLoading.vue";
import SMPageStatus from "../components/SMPageStatus.vue";
import { strCaseCmp } from "../helpers/string"; import { strCaseCmp } from "../helpers/string";
import { useUserStore } from "../store/UserStore"; import { useUserStore } from "../store/UserStore";
import { Form, FormControl, FormObject } from "../helpers/form"; import { Form, FormControl, FormObject } from "../helpers/form";
@@ -39,7 +66,7 @@ import { Required } from "../helpers/validate";
const pageStatus = ref(200); const pageStatus = ref(200);
const pageLoading = ref(true); const pageLoading = ref(true);
const showPasswordForm = ref(false); const showForm = ref("");
const fileUrl = ref(""); const fileUrl = ref("");
const userStore = useUserStore(); const userStore = useUserStore();
@@ -71,6 +98,9 @@ const downloadFile = (params = {}) => {
} }
window.location.href = url; window.location.href = url;
window.setTimeout(() => {
showForm.value = "complete";
}, 1500);
}; };
/* /*
@@ -84,6 +114,10 @@ const handleSubmit = () => {
downloadFile(params); downloadFile(params);
}; };
const handleClose = () => {
window.close();
};
/** /**
* Handle page loading * Handle page loading
*/ */
@@ -96,34 +130,45 @@ const handleLoad = async () => {
id: route.params.id, id: route.params.id,
}; };
let result = await api.get({ try {
url: "/media/:id", let result = await api.get({
params: params, url: "/media/{id}",
}); params: params,
});
if (result.status === 200) { if (result.status === 200) {
const medium = result.data as Media; const data = result.data as MediaResponse;
fileUrl.value = medium.url; const medium = data.medium as Media;
fileUrl.value = medium.url;
if (medium.security_type === "") { if (medium.security_type === "") {
downloadFile(); downloadFile();
} else if ( } else if (
strCaseCmp("permission", medium.security_type) === true && strCaseCmp("permission", medium.security_type) === true &&
userStore.id userStore.id
) { ) {
const params = { const params = {
token: userStore.token, token: userStore.token,
}; };
downloadFile(params); downloadFile(params);
} else if (strCaseCmp("password", medium.security_type) === true) { } else if (
showPasswordForm.value = true; strCaseCmp("password", medium.security_type) === true
) {
showForm.value = "password";
} else {
/* unknown security type */
pageStatus.value = 403;
}
pageLoading.value = false;
} else { } else {
/* unknown security type */ pageStatus.value = result.status;
pageStatus.value = 403; pageLoading.value = false;
} }
} else { } catch (error) {
pageStatus.value = result.status; pageStatus.value = error.status;
pageLoading.value = false;
} }
} }
}; };

View File

@@ -74,12 +74,24 @@
<template #item-title="item"> <template #item-title="item">
<div class="flex gap-2"> <div class="flex gap-2">
<div <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="{ :style="{
backgroundImage: `url('${mediaGetThumbnail( backgroundImage: `url('${mediaGetThumbnail(
item, 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"> <div class="flex flex-col flex-justify-center">
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
<span class="small">({{ item.name }})</span> <span class="small">({{ item.name }})</span>
@@ -101,11 +113,11 @@
fill="currentColor" /> fill="currentColor" />
</svg> </svg>
</button> </button>
<button <a
type="button" :href="mediaGetWebURL(item)"
class="bg-transparent cursor-pointer hover:text-sky-5" class="bg-transparent cursor-pointer hover:text-sky-5"
title="Download" title="Download"
@click="handleDownload(item)"> target="_blank">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960" 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" 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" /> fill="currrentColor" />
</svg> </svg>
</button> </a>
<button <button
type="button" type="button"
class="bg-transparent cursor-pointer hover:text-red-7" 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 SMTable from "../../components/SMTable.vue";
import SMPagination from "../../components/SMPagination.vue"; import SMPagination from "../../components/SMPagination.vue";
import SMLoading from "../../components/SMLoading.vue"; import SMLoading from "../../components/SMLoading.vue";
import { updateRouterParams } from "../../helpers/url"; import { addQueryParam, updateRouterParams } from "../../helpers/url";
import { userHasPermission } from "../../helpers/utils"; import { userHasPermission } from "../../helpers/utils";
import SMPageStatus from "../../components/SMPageStatus.vue"; import SMPageStatus from "../../components/SMPageStatus.vue";
import SMCheckbox from "../../components/SMCheckbox.vue"; import SMCheckbox from "../../components/SMCheckbox.vue";
import { mediaGetThumbnail } from "../../helpers/media"; import { mediaGetThumbnail, mediaGetWebURL } from "../../helpers/media";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -448,7 +460,9 @@ const handleEditSelected = async () => {
* @param {Media} item The media item. * @param {Media} item The media item.
*/ */
const handleDownload = (item: Media) => { 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(() => { 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', [MediaJobController::class, 'index']);
Route::get('media/jobs/{mediaJob}', [MediaJobController::class, 'show']); Route::get('media/jobs/{mediaJob}', [MediaJobController::class, 'show']);
Route::apiResource('media', MediaController::class); 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::apiResource('articles', ArticleController::class);
// Route::apiAddendumResource('attachments', 'articles', ArticleController::class); // Route::apiAddendumResource('attachments', 'articles', ArticleController::class);