مطالب
React 16x - قسمت 29 - احراز هویت و اعتبارسنجی کاربران - بخش 4 - محافظت از مسیرها
در قسمت قبل، دکمه‌ی new movie را برای کاربران وارد نشده‌ی به سیستم، از صفحه‌ی نمایش لیست فیلم‌ها، مخفی کردیم. اما ... اگر آدرس http://localhost:3000/movies/new مستقیما در مرورگر وارد شود، هنوز هم برای عموم کاربران قابل دسترسی است.


روش محافظت از مسیریابی‌های تعریف شده‌ی در برنامه

شبیه به روشی را که در قسمت قبل، برای انتقال شیء user، به مسیریابی کامپوننت Movies استفاده کردیم:
<Route
     path="/movies"
     render={props => <Movies {...props} user={this.state.currentUser} />}
/>
در اینجا نیز می‌توان برای محافظت از یک مسیریابی، استفاده کرد. به همین جهت به app.js مراجعه کرده و مسیریابی فعلی کامپوننت MovieForm را:
<Route path="/movies/:id" component={MovieForm} />
به صورت زیر تغییر می‌دهیم:
<Route
  path="/movies/:id"
  render={props => {
    if (!this.state.currentUser) {
      return <Redirect to="/login" />;
    }
    return <MovieForm {...props} />;
  }}
/>
اینبار نیز بجای ویژگی component، از ویژگی render استفاده می‌کنیم تا بتوان در اینجا به صورت پویا، کدنویسی کرد. ابتدا بررسی می‌کنیم که آیا کاربر جاری تنظیم شده‌است؟ اگر خیر، او را به صفحه‌ی لاگین هدایت می‌کنیم؛ در غیراینصورت، همان کامپوننت MovieForm را به همراه تمام props مرتبط با آن، بازگشت می‌دهیم.

اکنون اگر این تغییرات را ذخیره کرده و در حالت Logout، مسیر http://localhost:3000/movies/new را مستقیما درخواست دهیم، به صفحه‌ی لاگین هدایت خواهیم شد.


ایجاد کامپوننتی با قابلیت استفاده‌ی مجدد، برای محافظت از مسیریابی‌ها

هرچند روشی که تا اینجا برای محافظت از مسیریابی‌ها معرفی شد، بدون مشکل کار می‌کند، اما اگر قرار باشد برای تمام مسیریابی‌های اینگونه، استفاده شود، به تکرار بیش از اندازه‌ی کدهای یکسانی خواهیم رسید. به همین جهت می‌توان این منطق را تبدیل به یک کامپوننت با قابلیت استفاده‌ی مجدد کرد؛ تا دیگر نیازی به تکرار این if/else‌ها نباشد. برای این منظور، فایل جدید src\components\common\protectedRoute.jsx را ایجاد می‌کنیم. کامپوننت جدید protectedRoute را هم در پوشه‌ی common قرار داده‌ایم؛ چون وابستگی به دومین این برنامه نداشته و می‌تواند در سایر برنامه نیز مورد استفاده قرار گیرد. سپس با استفاده از میانبرهای imrc و sfc، یک کامپوننت تابعی بدون حالت را به نام ProtectedRoute ایجاد کرده و در آن، همان کامپوننت اصلی Route را بازگشت می‌دهیم. بنابراین هر زمانیکه از ProtectedRoute استفاده شود، خروجی آن، همان کامپوننت استاندارد Route خواهد بود که اینبار قرار است از وضعیت کاربر جاری وارد شده‌ی به سیستم، مطلع باشد. به همین جهت در اولین قدم، همان قطعه کد Route فوق را که به همراه if/else نوشتیم، از فایل app.js کپی کرده و به اینجا، داخل متد رندر کامپوننت، منتقل می‌کنیم. سپس شروع می‌کنیم به متغیر کردن عباراتی که در آن به صورت صریح و ثابت، مقدار دهی شده‌اند تا به یک کامپوننت با قابلیت استفاده‌ی مجدد برسیم:
import React from "react";
import { Route, Redirect } from "react-router-dom";
import * as auth from "../../services/authService";

const ProtectedRoute = ({ path, component: Component, render, ...rest }) => {
  return (
    <Route
      {...rest}
      render={props => {
        if (!auth.getCurrentUser())
          return (
            <Redirect
              to={{
                pathname: "/login",
                state: { from: props.location }
              }}
            />
          );
        return Component ? <Component {...props} /> : render(props);
      }}
    />
  );
};

export default ProtectedRoute;
- در ابتدا بجای ذکر props بعنوان پارامتر این کامپوننت، از طریق Object Destructuring، خواصی را که قرار است به صورت props دریافت کنیم، مشخص کرده‌ایم. مزیت اینکار، مشخص شدن اینترفیس این کامپوننت به نحو واضحی است. برای مثال بجای ذکر مقدار ویژگی path، به صورت یک رشته‌ی ثابت، آن‌را از طریق یک متغیر دریافت می‌کنیم.
- در این کامپوننت نیاز است اطلاعات کاربر جاری وارد شده‌ی به سیستم در دسترس باشد. یا می‌توان آن‌را به عنوان یکی از خواص props دریافت کرد و یا همانند این مثال، امکان دریافت مستقیم آن از  authService نیز وجود دارد.
- در ادامه اگر CurrentUser مقدار دهی نشده باشد، کامپوننت Redirect را که کاربر را به صفحه‌ی لاگین هدایت می‌کند، بازگشت می‌دهیم. در غیراینصورت نیاز است یک کامپوننت را بجای برای مثال MovieForm، بازگشت دهیم. علت استفاده‌ی از component: Component این است که React انتظار دارد، کامپوننت‌ها با نام بزرگ شروع شوند. به همین جهت خاصیت component را از props دریافت کرده و آن‌را به Component تغییر نام می‌دهیم.
- زمانیکه از کامپوننت Route استاندارد استفاده می‌شود، یا از ویژگی component آن استفاده می‌شود و یا از ویژگی render آن که یک تابع است، تا بتوان داخل آن، کدهای پویایی را درج کرد. به همین جهت ممکن است که مقدار متغیر کامپوننت دریافت شده، نال باشد. بنابراین در اینجا بررسی می‌شود که آیا Component، مقدار دهی شده‌است یا خیر؟ اگر بله، همان کامپوننت را به همراه props آن بازگشت می‌دهیم. در غیراینصورت، متد render مقدار دهی شده را به همراه props ارسالی به آن، بازگشت خواهیم داد.
- علت وجود پارامتر rest نیز این است که این کامپوننت علاوه بر ویژگی‌هایی که تاکنون پیش بینی کرده‌ایم، ممکن است در آینده ویژگی‌های دیگری را نیز نیاز داشته باشد. به همین جهت مابقی آن‌ها را توسط {rest...}، به صورت خودکار در اینجا درج می‌کنیم. برای نمونه در اینجا ذکر path={path} را مشاهده نمی‌کنید؛ چون توسط همان {rest...} به صورت خودکار تامین می‌شود.

اکنون به app.js بازگشته و کدهای قبلی را با این کامپوننت جدید ProtectedRoute، جایگزین می‌کنیم:
import ProtectedRoute from "./components/common/protectedRoute";
// ...

<ProtectedRoute path="/movies/:id" component={MovieForm} />
اینبار نحوه‌ی تعریف ProtectedRoute، همانند نحوه‌ی تعریف کامپوننت Route استاندارد است؛ با این تفاوت که این کامپوننت در پشت صحنه، از وضعیت کاربر جاری سیستم مطلع است و بر اساس آن واکنش نشان می‌دهد.


مدیریت بازگشت کاربران، پس از لاگین به سیستم

پس از خروج از برنامه، اگر سعی در ویرایش یکی از فیلم‌های موجود کنیم، به صفحه‌ی لاگین هدایت خواهیم شد. پس از لاگین موفق، مجددا به ریشه‌ی سایت بازگشت داده می‌شویم و نه به صفحه‌ای که پیش از لاگین، مدنظر کاربر بوده‌است. برای رفع این مشکل نیاز است بتوان به آدرس قبلی درخواستی، دسترسی یافت و این مورد توسط سیستم مسیریابی، به کامپوننت‌ها به صورت خودکار تزریق می‌شود. برای مثال اگر در کامپوننت ProtectedRoute، مقدار شیء props دریافتی را لاگ کنیم:
  return (
    <Route
      {...rest}
      render={props => {
        console.log(props);
و سپس بر روی لینک به مشاهده‌ی جزئیات و ویرایش یک فیلم کلیک کنیم، تصویر زیر حاصل می‌شود:


همانطور که مشخص است، شیء location دریافتی از props، به همراه اطلاعات آدرسی است که پیش از هدایت خودکار به صفحه‌ی لاگین، درخواست کرده بودیم. به همین جهت یک چنین تنظیمی، در تعاریف کامپوننت ProtectedRoute درنظر گرفته شده‌اند:
<Redirect
              to={{
                pathname: "/login",
                state: { from: props.location }
              }}
            />
در کامپوننت Redirect، مقدار to می‌تواند یک رشته و یا یک شیء باشد. اگر حالت انتساب یک شیء را انتخاب کردیم، خاصیت pathname آن مانند قبل است و مکان نهایی Redirect را مشخص می‌کند. اما کار خاصیت state آن، ارسال اطلاعاتی اضافی است به کامپوننتی که قرار است کار Redirect به آن صورت گیرد. برای مثال در تنظیم فوق، شیء ای که دارای خاصیت from و با مقدار props.location است، به صورت خودکار به کامپوننت مقصد ارسال می‌شود.
اکنون که این شیء، به کامپوننت لاگین، پس از Redirect خودکار ارسال می‌شود، نیاز است به src\components\loginForm.jsx مراجعه کرده و تغییرات زیر را اعمال کنیم:
  doSubmit = async () => {
    try {
      const { data } = this.state;
      await auth.login(data.username, data.password);

      const { state } = this.props.location;
      window.location = state ? state.from.pathname : "/";
    } catch (ex) {
      //...
در اینجا خاصیت state، از شیء location تزریق شده‌ی به props این کامپوننت، استخراج می‌شود. سپس با مقدار دهی window.location به from.pathname آن، کار هدایت کاربر را پس از لاگین موفق، به آدرس قبلی مدنظر او، انجام می‌دهیم.

تا اینجا اگر برنامه را ذخیره کرده، از سیستم خارج شویم و سعی در ویرایش اولین رکورد موجود در لیست فیلم‌ها کنیم، ابتدا به صفحه‌ی لاگین هدایت می‌شویم. پس از لاگین موفق، اینبار بجای مشاهده‌ی ریشه‌ی سایت که در اینجا به لیست فیلم‌ها تنظیم شده، دقیقا صفحه‌ی ویرایش جزئیات اولین فیلم را مشاهده خواهیم کرد.


عدم نمایش مجدد صفحه‌ی لاگین، به کاربران وارد شده‌ی به سیستم

آخرین تغییری را که در اینجا اعمال خواهیم کرد، رفع مشکل امکان مشاهده‌ی مجدد صفحه‌ی لاگین، با وارد کردن مستقیم آدرس آن در مرورگر، پس از ورود موفقیت آمیز به سیستم است. برای این منظور، ابتدای متد رندر کامپوننت فرم لاگین را به صورت زیر تغییر می‌دهیم تا اگر کاربر، پیشتر به سیستم وارد شده بود، به صورت خودکار به ریشه‌ی سایت هدایت شده و مجددا فرم لاگین برای او رندر نشود:
import { Redirect } from "react-router-dom";
//...


  render() {
    if (auth.getCurrentUser()) return <Redirect to="/" />;


کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید: sample-29-backend.zip و sample-29-frontend.zip
مطالب
فعال سازی قسمت آپلود تصویر و فایل Kendo UI Editor
یکی دیگر از ویجت‌های Kendo UI یک HTML Editor کامل است به همراه امکانات ارسال فایل، تصویر و ... پشتیبانی از راست به چپ. در ادامه قصد داریم نحوه‌ی مدیریت نمایش لیست فایل‌ها، افزودن و حذف آن‌ها را از طریق این ادیتور بررسی کنیم.


تنظیمات ابتدایی Kendo UI Editor

در ذیل کدهای سمت کاربر فعال سازی مقدماتی Kendo UI را مشاهده می‌کنید. در قسمت tools آن، لیست امکانات و نوار ابزار مهیای آن درج شده‌اند.
دو مورد insertImage و insertFile آن نیاز به تنظیمات سمت کاربر و سرور بیشتری دارند.
<!--نحوه‌ی راست به چپ سازی -->
<div class="k-rtl">
    <textarea id="editor" rows="10" cols="30" style="height: 440px"></textarea>
</div>
 
@section JavaScript
{
    <script type="text/javascript">
        $(function () {
            $("#editor").kendoEditor({
                tools: [
                    "bold", "italic", "underline", "strikethrough", "justifyLeft",
                    "justifyCenter", "justifyRight", "justifyFull", "insertUnorderedList",
                    "insertOrderedList", "indent", "outdent", "createLink", "unlink",
                    "insertImage", "insertFile",
                    "subscript", "superscript", "createTable", "addRowAbove", "addRowBelow",
                    "addColumnLeft", "addColumnRight", "deleteRow", "deleteColumn", "viewHtml",
                    "formatting", "cleanFormatting", "fontName", "fontSize", "foreColor",
                    "backColor", "print"
                ],
                imageBrowser: {
                    messages: {
                        dropFilesHere: "فایل‌های خود را به اینجا کشیده و رها کنید"
                    },
                    transport: {
                        read: {
                            url: "@Url.Action("GetFilesList", "KendoEditorImages")",
                            dataType: "json",
                            contentType: 'application/json; charset=utf-8',
                            type: 'GET',
                            cache: false
                        },
                        destroy: {
                            url: "@Url.Action("DestroyFile", "KendoEditorImages")",
                            type: "POST"
                        },
                        create: {
                            url: "@Url.Action("CreateFolder", "KendoEditorImages")",
                            type: "POST"
                        },
                        thumbnailUrl: "@Url.Action("GetThumbnail", "KendoEditorImages")",
                        uploadUrl: "@Url.Action("UploadFile", "KendoEditorImages")",
                        imageUrl: "@Url.Action("GetFile", "KendoEditorImages")?path={0}"
                    }
                },
                fileBrowser: {
                    messages: {
                        dropFilesHere: "فایل‌های خود را به اینجا کشیده و رها کنید"
                    },
                    transport: {
                        read: {
                            url: "@Url.Action("GetFilesList", "KendoEditorFiles")",
                            dataType: "json",
                            contentType: 'application/json; charset=utf-8',
                            type: 'GET',
                            cache: false
                        },
                        destroy: {
                            url: "@Url.Action("DestroyFile", "KendoEditorFiles")",
                            type: "POST"
                        },
                        create: {
                            url: "@Url.Action("CreateFolder", "KendoEditorFiles")",
                            type: "POST"
                        },
                        uploadUrl: "@Url.Action("UploadFile", "KendoEditorFiles")",
                        fileUrl: "@Url.Action("GetFile", "KendoEditorFiles")?path={0}"
                    }
                }
            });
        });
    </script>
}
در اینجا نحوه‌ی تنظیم مسیرهای مختلف ارسال فایل و تصویر Kendo UI Editor را ملاحظه می‌کنید.
منهای قسمت thumbnailUrl، عملکرد قسمت‌های مختلف افزودن فایل و تصویر این ادیتور یکسان هستند. به همین جهت می‌توان برای مثال کنترلی مانند KendoEditorFilesController را ایجاد و سپس در کنترلر KendoEditorImagesController از آن ارث بری کرد و متد دریافت و نمایش بند انگشتی تصاویر را افزود. به این ترتیب دیگر نیازی به تکرار کدهای مشترک بین این دو قسمت نخواهد بود.


نمایش لیست پوشه‌ها و تصویر در ابتدای باز شدن صفحه‌ی درج تصویر

با کلیک بر روی دکمه‌ی نمایش لیست تصاویر، صفحه دیالوگی مانند شکل زیر ظاهر خواهد شد:


تنظیمات خواندن این فایل‌ها، از قسمت read مربوط به imageBrowser دریافت می‌شود که cache آن نیز به false تنظیم شده‌است تا در این بین مرورگر اطلاعات را کش نکند. این مورد در حین حذف فایل‌ها و پوشه‌ها مهم است. زیرا اگر cache:false تنظیم نشده باشد، حذف یک فایل یا پوشه در سمت کاربر تاثیری نخواهد داشت.
imageBrowser: {
    transport: {
        read: {
            url: "@Url.Action("GetFilesList", "KendoEditorImages")",
            dataType: "json",
            contentType: 'application/json; charset=utf-8',
            type: 'GET',
            cache: false
        }
    }
},
در ادامه نیاز است اکشن متد GetFilesList را به نحو ذیل در سمت سرور تهیه کرد:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";
 
        [HttpGet]
        public ActionResult GetFilesList(string path)
        {
            path = GetSafeDirPath(path);
            var imagesList = new DirectoryInfo(path)
                                .GetFiles()
                                .Select(fileInfo => new KendoFile
                                {
                                    Name = fileInfo.Name,
                                    Size = fileInfo.Length,
                                    Type = KendoFileType
                                }).ToList();
 
            var foldersList = new DirectoryInfo(path)
                                .GetDirectories()
                                .Select(directoryInfo => new KendoFile
                                {
                                    Name = directoryInfo.Name,
                                    Type = KendoDirType
                                }).ToList();
 
            return new ContentResult
            {
                Content = JsonConvert.SerializeObject(imagesList.Union(foldersList), new JsonSerializerSettings
                {
                    ContractResolver = new CamelCasePropertyNamesContractResolver()
                }),
                ContentType = "application/json",
                ContentEncoding = Encoding.UTF8
            };
        }
 
 
        protected string GetSafeDirPath(string path)
        {
            // path = مسیر زیر پوشه‌ی وارد شده
            if (string.IsNullOrWhiteSpace(path))
            {
                return Server.MapPath(FilesFolder);
            }
 
            //تمیز سازی امنیتی
            path = Path.GetDirectoryName(path);
            path = Path.Combine(Server.MapPath(FilesFolder), path);
            return path;
        } 
    }
}
در اینجا کدهای کلاس پایه KendoEditorFilesController را مشاهده می‌کنید. به این جهت فیلد FilesFolder آن protected تعریف شده‌است تا در کلاسی که از آن ارث بری می‌کند نیز قابل دسترسی باشد. سپس لیست فایل‌ها و پوشه‌های path دریافتی با فرمت لیستی از KendoFile تهیه شده و با فرمت JSON بازگشت داده می‌شوند. ساختار KendoFile را در ذیل مشاهده می‌کنید:
namespace KendoUI13.Models
{
    public class KendoFile
    {
        public string Name { set; get; }
        public string Type { set; get; }
        public long Size { set; get; }
    }
}
- در اینجا Type می‌تواند از نوع فایل با مقدار f و یا از نوع پوشه با مقدار d باشد.
- علت استفاده از CamelCasePropertyNamesContractResolver در حین بازگشت JSON نهایی، تبدیل خواص دات نتی، به نام‌های سازگار با JavaScript است. برای مثال به صورت خودکار Name را تبدیل به name می‌کند.
- پارامتر path در ابتدای کار خالی است. اما کاربر می‌تواند در بین پوشه‌های باز شده‌ی توسط مرورگر تصاویر Kendo UI حرکت کند. به همین جهت مقدار آن باید هربار بررسی شده و بر این اساس لیست فایل‌ها و پوشه‌های جاری بازگشت داده شوند.


مدیریت حذف تصاویر و پوشه‌ها

همانطور که در شکل فوق نیز مشخص است، با انتخاب یک پوشه یا فایل، دکمه‌ای با آیکن ضربدر جهت فراهم آوردن امکان حذف، ظاهر می‌شود. این دکمه متصل است به قسمت destroy تنظیمات ادیتور:
imageBrowser: {
    transport: {
        destroy: {
            url: "@Url.Action("DestroyFile", "KendoEditorImages")",
            type: "POST"
        }
    }
},
این تنظیمات سمت کاربر را باید به نحو ذیل در سمت سرور مدیریت کرد:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";
 
        [HttpPost]
        public ActionResult DestroyFile(string name, string path)
        {
            //تمیز سازی امنیتی
            name = Path.GetFileName(name);
            path = GetSafeDirPath(path);
 
            var pathToDelete = Path.Combine(path, name);
 
            var attr = System.IO.File.GetAttributes(pathToDelete);
            if ((attr & FileAttributes.Directory) == FileAttributes.Directory)
            {
                Directory.Delete(pathToDelete, recursive: true);
            }
            else
            {
                System.IO.File.Delete(pathToDelete);
            }
 
            return Json(new object[0]);
        } 
    }
}
- استفاده از Path.GetFileName جهت دریافت نام فایل‌ها در اینجا بسیار مهم است. زیرا اگر این تمیز سازی امنیتی صورت نگیرد، ممکن است با کمی تغییر در آن، فایل web.config برنامه، دریافت یا حذف شود.
- پارامتر name دریافتی مساوی است با نام فایل انتخاب شده و path مشخص می‌کند که در کدام پوشه قرار داریم.
- چون در اینجا امکان حذف یک پوشه یا فایل وجود دارد، حتما نیاز است بررسی کنیم، مسیر دریافتی پوشه‌است یا فایل و سپس بر این اساس جهت حذف آن‌ها اقدام صورت گیرد.


مدیریت ایجاد یک پوشه‌ی جدید

تنظیمات قسمت create مرورگر تصاویر، مرتبط است به زمانیکه کاربر با کلیک بر روی دکمه‌ی +، درخواست ایجاد یک پوشه‌ی جدید را کرده‌است:
imageBrowser: {
    transport: {
        create: {
            url: "@Url.Action("CreateFolder", "KendoEditorImages")",
            type: "POST"
        }
    }
},
کدهای اکشن متد متناظر با این عمل را در ذیل مشاهده می‌کنید:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";
 
        [HttpPost]
        public ActionResult CreateFolder(string name, string path)
        {
            //تمیز سازی امنیتی
            name = Path.GetFileName(name);
            path = GetSafeDirPath(path);
            var dirToCreate = Path.Combine(path, name);
 
            Directory.CreateDirectory(dirToCreate);
 
            return KendoFile(new KendoFile
            {
                Name = name,
                Type = KendoDirType
            });
        }
 
        protected ActionResult KendoFile(KendoFile file)
        {
            return new ContentResult
            {
                Content = JsonConvert.SerializeObject(file,
                    new JsonSerializerSettings
                    {
                        ContractResolver = new CamelCasePropertyNamesContractResolver()
                    }),
                ContentType = "application/json",
                ContentEncoding = Encoding.UTF8
            };
        }
    }
}
- در اینجا نیز name مساوی نام پوشه‌ی درخواستی است و path به مسیر تو در توی پوشه‌ی جاری اشاره می‌کند.
- پس از ایجاد پوشه، باید نام آن‌را با فرمت KendoFile به صورت JSON بازگشت داد. همچنین در اینجا Type را نیز باید به d (پوشه) تنظیم کرد.


مدیریت قسمت ارسال فایل و تصویر

زمانیکه کاربر بر روی دکمه‌ی upload file یا بارگذاری تصاویر در اینجا کلیک می‌کند، اطلاعات فایل آپلودی به مسیر uploadUrl ارسال می‌گردد.
imageBrowser: {
    transport: {
        thumbnailUrl: "@Url.Action("GetThumbnail", "KendoEditorImages")",
        uploadUrl: "@Url.Action("UploadFile", "KendoEditorImages")",
        imageUrl: "@Url.Action("GetFile", "KendoEditorImages")?path={0}"
    }
},
دو تنظیم دیگر thumbnailUrl و imageUrl، برای نمایش بند انگشتی و نمایش کامل تصویر کاربرد دارند.
در ادامه کدهای مدیریت سمت سرور قسمت آپلود این ادیتور را مشاهده می‌کنید:
namespace KendoUI13.Controllers
{
    public class KendoEditorFilesController : Controller
    {
        //مسیر پوشه فایل‌ها
        protected string FilesFolder = "~/files";
 
        protected string KendoFileType = "f";
        protected string KendoDirType = "d";

 
        [HttpPost]
        public ActionResult UploadFile(HttpPostedFileBase file, string path)
        {
            //تمیز سازی امنیتی
            var name = Path.GetFileName(file.FileName);
            path = GetSafeDirPath(path);
            var pathToSave = Path.Combine(path, name);
 
            file.SaveAs(pathToSave);
 
            return KendoFile(new KendoFile
            {
                Name = name,
                Size = file.ContentLength,
                Type = KendoFileType
            });
        } 
    }
}
- در اینجا path مشخص می‌کند که در کدام پوشه‌ی تو در تو قرار داریم و file نیز حاوی محتوای ارسالی به سرور است.
- پس از ذخیره سازی اطلاعات فایل، نیاز است اطلاعات فایل نهایی را با فرمت KendoFile به صورت JSON بازگشت دهیم.


ارث بری از KendoEditorFilesController جهت تکمیل قسمت مدیریت تصاویر

تا اینجا کدهایی را که ملاحظه کردید، برای هر دو قسمت ارسال تصویر و فایل کاربرد دارند. قسمت ارسال تصاویر برای تکمیل نیاز به متد دریافت تصاویر به صورت بند انگشتی نیز دارد که به صورت ذیل قابل تعریف است و چون از کلاس پایه KendoEditorFilesController ارث بری کرده‌است، این کنترلر به صورت خودکار حاوی اکشن متدهای کلاس پایه نیز خواهد بود.
using System.Web.Mvc;
 
namespace KendoUI13.Controllers
{
    public class KendoEditorImagesController : KendoEditorFilesController
    {
        public KendoEditorImagesController()
        {
            // بازنویسی مسیر پوشه‌ی فایل‌ها
            FilesFolder = "~/images";
        }
 
        [HttpGet]
        [OutputCache(Duration = 3600, VaryByParam = "path")]
        public ActionResult GetThumbnail(string path)
        {
            //todo: create thumb/ resize image
 
            path = GetSafeFileAndDirPath(path);
            return File(path, "image/png");
        }
    }
}

کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید.
مطالب
Blazor 5x - قسمت دوازدهم - مبانی Blazor - بخش 9 - یک تمرین
تا اینجا با مبانی Blazor آشنا شدیم. در این قسمت می‌خواهیم مثالی را بررسی کنیم که بسیاری از این مفاهیم ابتدایی را پوشش می‌دهد. برای نمونه می‌خواهیم یک کامپوننت modal بوت استرپی را جهت دریافت تائیدیه‌ی حذف اتاق‌های تعریف شده‌ی در مثال این سری نمایش دهیم که به همراه مفاهیمی است مانند فرگمنت‌ها جهت تعیین محتوای نمایشی مودال به صورت پویا، ارسال نتیجه‌ی انتخاب بله یا خیر از کامپوننت دریافت تائید، به کامپوننت والد، ارسال پارامترها به کامپوننت فرزند جهت نمایش عنوان و فراخوانی متدهای نمایش و مخفی کردن وهله‌ای از کامپوننت مودال، در کامپوننت والد؛ بدون یک سطر کدنویسی جاوا اسکریپتی!


مرور مثال این قسمت

تا اینجا در مثالی که بررسی کردیم، لیست اتاق‌ها توسط کامپوننت IndividualRoom.razor و لیست خدمات رفاهی یک هتل توسط کامپوننت IndividualAmenity.razor در کامپوننت والد DemoHotel.razor، نمایش داده شده‌اند:


دکمه‌های حذف و ویرایش هر اتاق نیز در کامپوننت EditDeleteButton.razor قرار دارند که توسط کامپوننت IndividualRoom.razor مورد استفاده قرار می‌گیرند.
اکنون می‌خواهیم با کلیک بر روی دکمه‌ی حذف کامپوننت EditDeleteButton، یک modal بوت استرپی جهت دریافت تائیدیه‌ی عملیات، نمایش داده شود و در صورت تائید آن، اتاق انتخابی از لیست اتاق‌های کامپوننت DemoHotel حذف گردد.


بنابراین در ابتدا کامپوننت EditDeleteButton، به کامپوننت IndividualRoom خبر درخواست حذف یک اتاق را می‌دهد. سپس کامپوننت IndividualRoom، یک مودال دریافت تائیدیه‌ی حذف را نمایش می‌دهد. پس از تائید حذف توسط کاربر، این رویداد به کامپوننت DemoHotel، جهت حذف اتاق انتخابی از لیست اتاق‌ها، اطلاع رسانی خواهد شد.


ایجاد کامپوننت مودال دریافت تائید

در ابتدا، فایل جدید Pages\LearnBlazor\LearnBlazor‍Components\Confirmation.razor را ایجاد کرده و به صورت زیر تکمیل می‌کنیم:
@if (ShowModal)
{
    <div class="modal-backdrop show"></div>

    <div class="modal fade show" id="exampleModal" tabindex="-1"
        role="dialog" aria-labelledby="exampleModalLabel"
        aria-hidden="true" style="display: block;">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">
                        @Title
                    </h5>
                    <button @onclick="OnCancelClicked" type="button" class="close">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    @ChildContent
                </div>
                <div class="modal-footer">
                    <button @onclick="OnCancelClicked" type="button" class="btn btn-secondary">@CancelButtonLabel</button>
                    <button @onclick="OnConfirmClicked" type="button" class="btn btn-primary">@OkButtonLabel</button>
                </div>
            </div>
        </div>
    </div>
}

@code {
    private bool ShowModal;

    [Parameter] public string Title { get; set; } = "Confirm";

    [Parameter] public string CancelButtonLabel { get; set; } = "Cancel";

    [Parameter] public string OkButtonLabel { get; set; } = "Ok";

    [Parameter] public RenderFragment ChildContent { get; set; }

    [Parameter] public EventCallback OnConfirm { get; set; }

    [Parameter] public EventCallback OnCancel { get; set; }

    public void Show() => ShowModal = true;

    public void Hide() => ShowModal = false;

    private async Task OnConfirmClicked()
    {
        ShowModal = false;
        await OnConfirm.InvokeAsync();
    }

    private async Task OnCancelClicked()
    {
        ShowModal = false;
        await OnCancel.InvokeAsync();
    }
}
توضیحات:
- در اینجا در ابتدا تگ‌ها و کلاس‌های مرتبط با نمایش یک modal استاندارد بوت استرپی را مشاهده می‌کنید.
- اگر فیلد خصوصی ShowModal به false تنظیم شود، چون کل محتوای این کامپوننت از DOM حذف خواهد شد (اثر if@ تعریف شده)، سبب مخفی شدن و عدم نمایش آن می‌گردد.
- این کامپوننت عنوان و برچسب‌های دکمه‌های خودش را به صورت پارامتر دریافت می‌کند.
- برای اینکه بتوان محتوای نمایشی این کامپوننت را پویا کرد، از یک RenderFragment استفاده کرده‌ایم:
[Parameter] public RenderFragment ChildContent { get; set; }
- خروجی این کامپوننت به والد یا فراخوان آن، دو رویداد OnConfirm و OnCancel هستند. همچنین چون نمی‌خواهیم کدهای مخفی کردن modal را به ازای هربار کلیک بر روی این دکمه‌ها فراخوانی کنیم، این رویدادها، ابتدا به دو متد خصوصی OnConfirmClicked و OnCancelClicked متصل شده‌اند، تا کار مخفی سازی و سپس هدایت این رویدادها را به کامپوننت والد انجام دهند.
- همچنین می‌خواهیم به کامپوننت فراخوان این امکان را بدهیم تا بتواند به صورت مستقل، سبب نمایش یا مخفی شدن وهله‌ای از این کامپوننت شود. به همین جهت دو متد عمومی Show و Hide نیز تعریف شده‌اند.


هدایت درخواست Delete به کامپوننت نمایش مشخصات اتاق

با توجه به اینکه دکمه‌های حذف و ویرایش هر اتاق، در کامپوننت Pages\LearnBlazor\LearnBlazor‍Components\EditDeleteButton.razor قرار دارند، به آن مراجعه کرده و امکان انتشار این رخ‌داد را به فراخوان آن، با تعریف رویداد OnDelete می‌دهیم:
@if (IsAdmin)
{
    <input type="button" class="btn btn-danger" value="Delete" @onclick="OnDelete" />
    <input type="button" class="btn btn-success" value="Edit" />
}

@code
{
    [Parameter]  public bool IsAdmin { get; set; }

    [Parameter] public EventCallback OnDelete { get; set; }
}


واکنش نشان دادن کامپوننت IndividualRoom.razor به درخواست حذف آن اتاق

کامپوننت Pages\LearnBlazor\LearnBlazor‍Components\IndividualRoom.razor که نمایش دهنده‌ی جزئیات هر اتاق است، با مدیریت رویداد OnDelete کامپوننت EditDeleteButton، از درخواست حذف اتاق جاری مطلع می‌شود:
<EditDeleteButton IsAdmin="true" OnDelete="OnDeleteClicked"></EditDeleteButton>

<Confirmation @ref="Confirmation1"
    OnCancel="OnCancelClicked"
    OnConfirm="@(() => OnDeleteSelectedRoom.InvokeAsync(Room))">
    <div>
        Do you want to delete `@Room.Name`?
    </div>
</Confirmation>
- در اینجا در ابتدا کامپوننت جدید Confirmation را مورد استفاده قرار داده و برای مثال محتوای «آیا می‌خواهید این اتاق را حذف کنید؟»، به صورت پویا به آن ارسال می‌کنیم که در این کامپوننت، توسط فرگمنت مرتبطی نمایش داده می‌شود.
- سپس نیاز است زمانیکه OnDelete کامپوننت EditDeleteButton رخ‌داد، این modal دریافت تائید را نمایش دهیم. به همین جهت باید بتوانیم متد عمومی Show آن‌را فراخوانی کنیم. بنابراین از ref@ برای دسترسی به وهله‌ای از این کامپوننت تعریف شده استفاده کرده‌ایم تا توسط شیء Confirmation1، بتوانیم متد عمومی Show را در رویدادگردان منتسب به OnDelete فراخوانی کنیم.
- همچنین دو رویداد OnCancel و OnConfirm کامپوننت دریافت تائید را به متد خصوصی OnCancelClicked و رویداد جدید OnDeleteSelectedRoom متصل کرده‌ایم. یعنی زمانیکه کاربر بر روی دکمه‌ی OK مودال ظاهر شده کلیک می‌کند، Room جاری، از طریق رویداد OnDeleteSelectedRoom به فراخوان کامپوننت IndividualRoom ارسال می‌شود تا دقیقا بداند که چه اتاقی را بایدحذف کند:
@code
{
    Confirmation Confirmation1;

    [Parameter]
    public BlazorRoom Room { get; set; }

    [Parameter]
    public EventCallback<BlazorRoom> OnDeleteSelectedRoom { get; set; }

    void OnDeleteClicked()
    {
        Confirmation1.Show();
    }

    void OnCancelClicked()
    {
        // Confirmation1.Hide();
    }

   // ...
}
بنابراین کامپوننت IndividualRoom، یک شیء Room را از والد خود دریافت کرده و مشخصات آن‌را نمایش می‌دهد. همچنین پس از تائید حذف این اتاق، آن‌را از طریق رویداد جدید OnDeleteSelectedRoom به والد خود اطلاع رسانی می‌کند.


حذف اتاق انتخابی در کامپوننت نمایش لیست اتاق‌ها

مرحله‌ی آخر این مثال، بسیار ساده‌است. در حلقه‌ای که هر اتاق را توسط کامپوننت IndividualRoom نمایش می‌دهد، به رویداد OnDeleteSelectedRoom گوش فرا داده و selectedRoom یا همان BlazorRoom ارسالی را، دریافت و از لیست Rooms کامپوننت جاری حذف می‌کنیم. این حذف شدن، بلافاصله سبب رندر مجدد UI و حذف آن از رابط کاربری نیز خواهد شد:
@foreach (var room in Rooms)
        {
            <IndividualRoom
                OnRoomCheckBoxSelection="RoomSelectionCounterChanged"
                Room="room"
                OnDeleteSelectedRoom="@(selectedRoom => Rooms.Remove(selectedRoom))">
            </IndividualRoom>
        }


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: Blazor-5x-Part-12.zip
بازخوردهای دوره
حذف یک ردیف از اطلاعات به همراه پویانمایی محو شدن اطلاعات آن توسط jQuery در ASP.NET MVC
توکن را به view اضافه کنید (یکبار در بالای صفحه). در قسمت JSON.stringify مقدار آن‌را خوانده و ارسال کنید:
var token = $('[name=__RequestVerificationToken]').val();

$.ajax({
    // .....
    data: { __RequestVerificationToken: token, ..... },
    // .....
});
مطالب
افزونه جی کوئری RowAdder
دیروز در یک برنامه میخواستم کاربر بتواند لیست مواد مصرفی یک کارخانه را ایجاد کند که نیاز بود کاربر بتواند از هر سطر به تعداد نامحدود ایجاد کند و برای انتخاب هر یک از مواد به همراه جزئیات آن یک سطر به لیست اضافه شود. برای اینکار میتوانیم با استفاده از فناوری جی کوئری اینکار را انجام دهیم ولی بهتر بود که این مورد به یک افزونه تبدیل میشد تا در دفعات بعدی بسیار راحت‌تر باشیم. جهت آشنایی با پلاگین نویسی بهتر هست این مقالات (+) را مطالعه فرمایید.

نحوه استفاده
نحوه استفاده از آن بسیار راحت است و در دموی html همراه آن به طور ساده در سه مثال توضیح داده شده است. ابتدا از این آدرس کتابخانه آن را دریافت کنید. این کتابخانه شامل یک فایل js که شامل کدهای پلاگین است، یک فایل css جهت تغییر استایل کدهایی است که پلاگین تولید میکند که اسامی آن دقیقا مشخص می‌کند که هر کلاس متعلق به چه بخشی است.

گام اول:
فایل‌های مورد نظر را بعد از صدا زدن کتابخانه‌ی جی کوئری صدا بزنید.
<link type="text/css" href="css/RowAdder.css" rel="stylesheet" />
    <script src="js/RowAdder.js" type="text/javascript"></script>


گام دوم :
 در تکه کدهای html، کدی را که قرار است در هر سطر تکرار شود، داخل یک div قرار داده و نامی مثل row-sample را برای آن قرار دهید (فعلا حتما این نام باشد)، بعدها پلاگین، کدهای داخل این تگ div را به عنوان هر سطر خواهد شناخت:
<div id="row-sample">
    <form style="margin: 0; padding: 0;">
        Name:<input type="text"/>
        <input type="radio" name="Gender" value="male" checked="checked">Male
        <input type="radio" name="Gender" value="female">Female
    </form>
</div>


گام سوم:
 سپس یک div دیگر ایجاد کنید و نامی مثل mypanel را به آن بدهید تا سطرهایی که ایجاد می‌شوند داخل این div قرار بگیرند.
<div id="mypanel"></div>

گام چهارم:
در بخش head یک تگ اسکریپت باز کرده و کدهای زیر را به آن اضافه می‌کنیم. این کد باعث می‌شود که پلاگین فعال شود.
<script>
$(document).ready(function() {
$("#mypanel").RowAdder();
});
</script>
گام پنجم:
 یک دکمه جهت افزودن سطر به صفحه اضافه می‌کنیم
<button id="addanotherform">Add New Form</button>

و در قسمت تگ اسکریپت هم کد زیر را اضافه می‌کنیم:
$("#addanotherform").on('click', function() {
                $("#mypanel").RowAdder('add');
            });

حال از صفحه تست می‌گیریم: با هر بار کلیک بر روی دکمه‌ی Add New Form یک سطر جدید ایجاد می‌گردد.


در تصویر بالا دکمه‌های دیگر هم دیده می‌شوند که به دیگر متدهای آن اشاره دارد:

جهت مخفی سازی:
 $("#mypanel").RowAdder('hide');

چهت نمایش:
$("#mypanel").RowAdder('show');

جهت افزودن سطر با کد:
$("#mypanel").RowAdder('add');

جهت دریافت تعداد سطرهای ایجاد شده:
$("#mypanel").RowAdder('count')


جهت دریافت کدهای یک سطر در اندیس x

$("#mypanel").RowAdder('content', 3)

جهت حذف یک سطر با اندیس x
$("#mypanel").RowAdder('remove', 3);

همانطور که با صدا زدن اولین متد پلاگین متوجه شدید و نتیجه‌ی آن را در دمو دیدید، این پلاگین از پیش فرض‌هایی جهت راه اندازی اولیه استفاده می‌کند که این پیش فرض‌ها عبارتند از تگ row-sample که بدون معرفی رسمی، آن را شناسایی کرد. همچنین ممکن است بخواهید عبارت Remove را با کلمه‌ی فارسی «حذف» جایگزین نمایید. برای اینکار می‌توانید پلاگین را به شکل زیر به کار ببرید:
    $("#mypanel").RowAdder({
                sample: '#my-custom-sample',
                type: 'text',
                value:'حذف'
        });

تغییر اولین پیش فرض، تغییر نام تگ row-sample به my-custom-sample بود و در مرحله‌ی بعد هم نام فارسی حذف را جایگزین remove کردیم. عبارت type به طور پیش فرض بر روی text قرار دارد که اجباری به ذکر آن در کد بالا نبود. ولی اگر دوست دارید که به جای نمایش عبارت حذف، از یک آیکن یا تصویر استفاده کنید، کد را به شکل زیر تغییر دهید:
  $("#mypanel").RowAdder({
                type: 'image',
                value: 'images/remove.png'
            });
در خطوط بالا عبارت type با image مقدار دهی شد و به پلاگین می‌گوید که به جای متن، از تصویر استفاده کن. همچنین value را به جای متن با آدرس تصویر مقداردهی کرده‌ایم و نتیجه را می‌توانید در دموی قرار گرفته در گیت هاب ببینید.

فایل RowAdder.css
در بردارنده هر سطر
.each-section {
    margin: 20px;
    padding: 5px;
}

جهت استایل بندی لینک چه تصویر و چه متن
.remove-link {
    color:#999;
     text-decoration: none;
}

a:hover.remove-link {
   color:#802727;
}
جهت تغییر استایل بر روی خود تصویر
.remove-image {
    
}

آشنایی با کد پلاگین
(function ($) {
    
    var settings = null;
  $.fn.RowAdder = function (method) {
    
            // call methods
            if (methods[method]) {
                return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
            } else if (typeof method === 'object' || !method) {
                return methods.init.apply(this, arguments);
            } else {
                $.error('Method ' + method + ' does not exist on jQuery.RowAdder');
            }
      

    };
})(jQuery);
در قسمت دوم آموزش پلاگین نویسی برای جی کوئری، متدها به طور واضح توضیح داده شده‌اند. این کدها وظیفه دارند متدهایی را که کاربر درخواست داده است، شناسایی و به همراه آرگومان‌های آن به سمت توابعی که به هر نام متد اختصاص داده‌ایم، ارسال کنند. در صورتیکه متدی با آرگومان‌های ناهماهنگی ارسال شوند، پیام خطایی ارسال می‌گردد و در صورتیکه تعریف نشود، به طور مستقیم init را صدا می‌زند. متغیر settings هم بعدا با تنظیمات پیش فرض پر می‌شود.

متدها
//methods
    var methods = {
        init: function (options) {
            //default-settings
             settings = $.extend({
                'sample': '#row-sample',
                'type': 'text',
                'value': 'Remove'
             }, options);
             this.attr('data-sample', settings.sample);
             this.attr('data-type', settings.type);
             this.attr('data-value', settings.value);
            Do(this);
        },
        show: function () {
            this.css("display", "inline");
        },
        hide: function () {
            this.css("display", "none");
        },
        add: function () {
            Do(this);
        },
        remove: function (index) {
            console.log(index);
           this.find(".each-section")[index].remove();
        },
        content: function (index) {
            return this.find(".each-section")[index];
        },
        count: function (index) {
            return this.find(".each-section").size();
        }
    };
متد init تنظیمات پیش فرض را دریافت می‌نماید و سپس بر روی المانی که پلاگین روی آن واقع شده‌است، مقادیر را ذخیره می‌کند تا در آینده با صدا زدن متدهای دیگر آن را استفاده نماید. کلمه‌ی this در واقع به تگی اشاره می‌کند که پلاگین روی آن اعمال شده است که در مثال‌های بالا mypanel نام داشت. متد Do تابع اصلی ما را در بر دارد که کدهای اصلی پلاگین را شامل می‌شود. مابقی متدها در واقع  جست و جویی بر المان‌ها هستند.

تابع Do
    function Do(panelDiv) {

        settings.sample = panelDiv.data('sample');
        settings.type = panelDiv.data('type');
        settings.value = panelDiv.data('value');
        //find sample code
        var rowsample = $(settings.sample);
        rowsample.css("display", "none");
        var sample = rowsample.html();


        var i = panelDiv.find(".each-section").size();
        //add html details to create a correct template
        var sectionDiv = $('<div />', { "class": 'each-section', 'id': 'section'+i });
        var image = $("<img />", { "src": settings.value,"class":"remove-image" });
        var link = $("<a />", { "text": settings.value,"class":"remove-link" });
        //remove event for remove selected form

        //create new form
        sectionDiv.html(sample);

        link.on('click', function (e) {

            e.preventDefault();
            var $this = $(this);
            $this.closest(".each-section").remove();
        });

        if (i > 0) {
            if (settings.type == 'image') {
                link.text('');
                link.append(image);

            }
            sectionDiv.append(link);
        }

        //add new created form on document
        panelDiv.append(sectionDiv);
       
    }
آرگومان داده شده، در واقع همان this هست که به این تابع ارسال شده است. در اولین گام تنظیمات ذخیره شده را که قبلا ذخیره کرده‌ایم، واکشی می‌کنیم. سپس تگ row-sample یا هر نامی را که به آن اختصاص داده شده است، می‌یابیم و محتوای آن را به شکل html در قالب string بیرون می‌کشیم. این کد html در واقع نمونه‌ای است که قرار است در سطر تکرار شود. البته تگ نمونه فقط برای نمونه به کار می‌رود و نیازی نیست روی صفحه نمایش داده شود؛ پس آن را مخفی می‌کنیم. از آنجا که ممکن است این سطری که ایجاد می‌شود، سطر اول نباشد و قبلا هم سطرهایی توسط همین متد ایجاد شده‌اند، بررسی می‌کنیم چند تگ با کلاس each-section داریم. اگر بیشتر از صفر باشد یعنی قبلا سطرهایی ایجاد شده است. در غیر اینصورت این اولین سطر ماست. اولین سطر توسط init صدا زده می‌شود و مابقی توسط متد add انجام می‌گیرد.
        settings.sample = panelDiv.data('sample');
        settings.type = panelDiv.data('type');
        settings.value = panelDiv.data('value');
        //find sample code
        var rowsample = $(settings.sample);
        rowsample.css("display", "none");
        var sample = rowsample.html();


        var i = panelDiv.find(".each-section").size();
در خطوط بعدی یک سری متغیر داریم که برای هر کدام یک قالب تگ div با کلاس‌های مختلف می‌سازیم. sectionDiv یک تگ  div  با کلاس each-section است که هر سطر را به طور کامل در خود قرار می‌دهد. link، جهت ساخت لینک حذف با کلاس remove-link به کار می‌رود. image هم یک تگ image می‌سازد تا اگر کاربر درخواست 'type:'image را داد، به جای لینک متنی حذف، از تصویر استفاده شود.
        //add html details to create a correct template
        var sectionDiv = $('<div />', { "class": 'each-section', 'id': 'section'+i });
        var image = $("<img />", { "src": settings.value,"class":"remove-image" });
        var link = $("<a />", { "text": settings.value,"class":"remove-link" });

در خط بعدی محتویات نمونه را داخل تگ sectiondiv قرار می‌دهیم:
//create new form
        sectionDiv.html(sample);

بعد از آن برای رویداد کلیک لینک حذف، کد زیر را وارد می‌کنیم:
   link.on('click', function (e) {

            e.preventDefault();
            var $this = $(this);
            $this.closest(".each-section").remove();
        });
متد closest در جی کوئری این وظیفه را دارد تا به سمت تگ‌های والد تگ this حرکت کند و با برخوردن با اولین تگ والد با کلاس each-section، آن تگ والد را بازگرداند و سپس متد remove را روی آن اجرا کند تا آن تگ به همراه تمام فرزندانش حذف شوند.

اولین شرط زیر بررسی می‌کند که آیا این سطری که ایجاد شده است سطر دوم به بعد است یا خیر؟ اگر آری پس باید دکمه‌ی حذف را به همراه داشته باشد. در صورتیکه سطر دوم به بعد باشد، وارد آن می‌شود. حالا بررسی می‌کند که کاربر برای دکمه‌ی حذف، درخواست لینک تصویری یا لینک متنی داده است و لینک مناسب را ساخته و آن را به انتهای sectionDiv اضافه می‌کند.
   if (i > 0) {
            if (settings.type == 'image') {
                link.text('');
                link.append(image);

            }
            sectionDiv.append(link);
        }

در انتها کل تگ sectionDiv را به تگ داده شده اضافه می‌کنیم تا به کاربر نمایش داده شود.
//add new created form on document
        panelDiv.append(sectionDiv);
بازخوردهای پروژه‌ها
سوال در مورد Authenticate_Request
با سلام. آیا منطقی است که در متد Authenticate_Request به ازای هر تقاضا یکبار برای دریافت اطلاعات کاربر به بانک کوئری زده شود؟ من در برخی از ویوها چندین تقاضا را به سرور بطور همزمان ارسال میکنم. آیا روش مذکور بهینه است؟ با تشکر.
مطالب
غیرمعتبر کردن توکن و یا کوکی سرقت شده در برنامه‌های مبتنی بر ASP.NET Core
چند روز قبل، یکی از کانال‌های فنی معروف یوتیوب با بیش از 15 میلیون مشترک، هک و پاک شد! که داستان آن‌را در اینجا می‌توانید پیگیری کنید. در این هک، مهاجم در سعی اول، پیشنهاد پشتیبانی مالی از شبکه را داده و در ایمیل دوم، پس از جلب اعتماد اولیه، یک فایل به ظاهر PDF مفاد قرارداد را ارسال کرده که با کلیک بر روی آن، تمام کوکی‌های یوتیوب مالک کانال، سرقت و مورد سوء استفاده قرار گرفته! در یک چنین حالتی، مهم نیست که شما اعتبارسنجی دو مرحله‌ای را فعال کرده‌اید و یا از بهترین روش‌های رمزنگاری برای امن کردن اطلاعات کوکی و یا توکن خود استفاده کرده‌اید، همینقدر که اصل محتوای کوکی و یا توکن شما در اختیار شخص دیگری قرار گیرد، می‌تواند بدون نیاز به لاگین و دانستن کلمه‌ی عبور شما، بجای شما وارد سیستم شده و تغییرات دلخواهی را اعمال کند!
بنابراین سؤال اینجاست که ما (توسعه دهندگان) چگونه می‌توانیم یک چنین حملاتی را مشکل‌تر کنیم؟ در این مطلب روشی را در جهت سعی در غیرمعتبر کردن توکن‌ها و یا کوکی‌های سرقت شده، در برنامه‌های مبتنی بر ASP.NET Core بررسی خواهیم کرد.


توسعه‌ی یک سرویس تشخیص مرورگر و سیستم عامل شخص وارد شده‌ی به سیستم

یکی از روش‌های غیرممکن کردن یک چنین حملاتی، درج مشخصات سیستم عامل و مرورگر شخص وارد شده‌ی به سیستم، در کوکی و همچنین توکن صادر شده‌ی حاصل از اعتبارسنجی موفق است. سپس زمانیکه قرار است از اطلاعات این کوکی و یا توکن در برنامه استفاده شود، این اطلاعات را با اطلاعات درخواست جاری کاربر مقایسه کرده و در صورت عدم تطابق، درخواست او را برگشت می‌زنیم. برای مثال اگر عملیات لاگین، در ویندوز انجام شده و اکنون توکن و یا کوکی حاصل، در سیستم عامل اندروید در حاصل استفاده‌است، یعنی ... این عملیات مشکوک است و باید خاتمه یابد و کاربر باید مجبور به لاگین مجدد شود و نه اعتبارسنجی خودکار بدون زحمت!
برای این منظور می‌توان از کتابخانه‌ی UA-Parser استفاده کرد و توسط آن سرویس زیر را توسعه داد:
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using UAParser;

namespace ASPNETCore2JwtAuthentication.Services;

/// <summary>
///     To invalidate an old user's token from a new device
/// </summary>
public class DeviceDetectionService : IDeviceDetectionService
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ISecurityService _securityService;

    public DeviceDetectionService(ISecurityService securityService, IHttpContextAccessor httpContextAccessor)
    {
        _securityService = securityService ?? throw new ArgumentNullException(nameof(securityService));
        _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
    }

    public string GetCurrentRequestDeviceDetails() => GetDeviceDetails(_httpContextAccessor.HttpContext);

    public string GetDeviceDetails(HttpContext context)
    {
        var ua = GetUserAgent(context);
        if (ua is null)
        {
            return "unknown";
        }

        var client = Parser.GetDefault().Parse(ua);
        var deviceInfo = client.Device.Family;
        var browserInfo = $"{client.UA.Family}, {client.UA.Major}.{client.UA.Minor}";
        var osInfo = $"{client.OS.Family}, {client.OS.Major}.{client.OS.Minor}";
        //TODO: Add the user's IP address here, if it's a banking system.
        return $"{deviceInfo}, {browserInfo}, {osInfo}";
    }

    public string GetDeviceDetailsHash(HttpContext context) =>
        _securityService.GetSha256Hash(GetDeviceDetails(context));

    public string GetCurrentRequestDeviceDetailsHash() => GetDeviceDetailsHash(_httpContextAccessor.HttpContext);

    public string GetCurrentUserTokenDeviceDetailsHash() =>
        GetUserTokenDeviceDetailsHash(_httpContextAccessor.HttpContext?.User.Identity as ClaimsIdentity);

    public string GetUserTokenDeviceDetailsHash(ClaimsIdentity claimsIdentity)
    {
        if (claimsIdentity?.Claims == null || !claimsIdentity.Claims.Any())
        {
            return null;
        }

        return claimsIdentity.FindFirst(ClaimTypes.System)?.Value;
    }

    public bool HasCurrentUserTokenValidDeviceDetails() =>
        HasUserTokenValidDeviceDetails(_httpContextAccessor.HttpContext?.User.Identity as ClaimsIdentity);

    public bool HasUserTokenValidDeviceDetails(ClaimsIdentity claimsIdentity) =>
        string.Equals(GetCurrentRequestDeviceDetailsHash(), GetUserTokenDeviceDetailsHash(claimsIdentity),
                      StringComparison.Ordinal);

    private static string GetUserAgent(HttpContext context)
    {
        if (context is null)
        {
            return null;
        }

        return context.Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgent)
                   ? userAgent.ToString()
                   : null;
    }
}
توضیحات:
اصل کار این سرویس در متد زیر رخ می‌دهد:
    public string GetDeviceDetails(HttpContext context)
    {
        var ua = GetUserAgent(context);
        if (ua is null)
        {
            return "unknown";
        }

        var client = Parser.GetDefault().Parse(ua);
        var deviceInfo = client.Device.Family;
        var browserInfo = $"{client.UA.Family}, {client.UA.Major}.{client.UA.Minor}";
        var osInfo = $"{client.OS.Family}, {client.OS.Major}.{client.OS.Minor}";
        //TODO: Add the user's IP address here, if it's a banking system.
        return $"{deviceInfo}, {browserInfo}, {osInfo}";
    }
در اینجا با استفاده از کتابخانه‌ی UA-Parser، سعی می‌کنیم تا جزئیات مرورگر و سیستم عامل شخص را تهیه کنیم. سپس در قسمت دیگری از این سرویس، این اطلاعات را هش می‌کنیم. از این جهت که هم حجم آن کاهش یابد و بی‌جهت کوکی و یا توکن ما را حجیم نکند و هم بررسی محتوای آن جهت شبیه سازی آن، غیرممکن شود. هر مشخصات دریافتی در حین لاگین، همواره یک هش مشخص و یکتا را دارد. به همین جهت متدهای هش کردن اطلاعات را هم در اینجا مشاهده می‌کنید. به علاوه‌ی متد HasUserTokenValidDeviceDetails که کار آن، دریافت Claim مرتبط با این اطلاعات، از کوکی و یا توکن جاری و مقایسه‌ی آن با اطلاعات Http Request جاری است. اگر این دو یکی نبودند، یعنی احتمال سوء استفاده‌ی از اطلاعات شخص، وجود دارد.


اضافه کردن اطلاعات مشخصات دستگاه کاربر به کوکی و یا توکن او

همانطور که عنوان شد، در متد HasUserTokenValidDeviceDetails، ابتدا مشخصات دستگاه موجود در کوکی و یا توکن دریافتی، استخراج می‌شود. به همین جهت نیاز است این مشخصات را دقیقا در حین لاگین موفق، به صورت یک Claim جدید، برای مثال از نوع ClaimTypes.System به مجموعه‌ی Claims کاربر اضافه کرد:
new(ClaimTypes.System, _deviceDetectionService.GetCurrentRequestDeviceDetailsHash(),
ClaimValueTypes.String, _configuration.Value.Issuer),


یکپارچه کردن DeviceDetectionService با اعتبارسنج‌های کوکی‌ها و توکن‌ها

پس از افزودن مشخصات سیستم کاربر وارد شده‌ی به سیستم، به صورت یک Claim جدید به توکن‌ها، روش اعتبارسنجی اطلاعات موجود در توکن رسیده، در رخ‌داد گردان OnTokenValidated است که امکان دسترسی به HttpContext و محتوای توکن را میسر می‌کند:
.AddJwtBearer(cfg =>
{
      cfg.Events = new JwtBearerEvents
      {
           OnTokenValidated = context =>
           {
               var tokenValidatorService = context.HttpContext.RequestServices.GetRequiredService<ITokenValidatorService>();
              return tokenValidatorService.ValidateAsync(context);
           },
       };
  });
و یا اگر از کوکی‌ها استفاده می‌کنید، معادل آن به صورت زیر است:
.AddCookie(options =>
{
    options.Events = new CookieAuthenticationEvents
    {
       OnValidatePrincipal = context =>
       {
         var cookieValidatorService = context.HttpContext.RequestServices.GetRequiredService<ICookieValidatorService>();
         return cookieValidatorService.ValidateAsync(context);
       }
    };
});

در کل تمام تغییرات مورد نیاز مرتبط را جهت یک برنامه‌ی تولید کننده‌ی JWT در اینجا و برای یک برنامه‌ی مبتنی بر کوکی‌ها در اینجا می‌توانید مشاهده کنید.
اشتراک‌ها
معرفی NativeScript

ساخت برنامه‌های چند سکویی موبایل با JavaScript و TypeScript. مخرن کد آن و native بودن آن به معنای دسترسی کامل به Platform API مورد استفاده‌است. مقاله‌ای در مورد نحوه‌ی استفاده از آن.

معرفی NativeScript
مطالب
آشنایی با NHibernate - قسمت دوم

آزمون واحد کلاس نگاشت تهیه شده

در مورد آشنایی با آزمون‌های واحد لطفا به برچسب مربوطه در سمت راست سایت مراجعه بفرمائید. همچنین در مورد اینکه چرا به این نوع API کلمه Fluent اطلاق می‌شود، می‌توان به تعریف آن جهت مطالعه بیشتر مراجعه نمود.

در این قسمت قصد داریم برای بررسی وضعیت کلاس نگاشت تهیه شده یک آزمون واحد تهیه کنیم. برای این منظور ارجاعی را به اسمبلی nunit.framework.dll به پروژه UnitTests که در ابتدای کار به solution جاری در VS.Net افزوده بودیم، اضافه نمائید (همچنین ارجاع‌هایی به اسمبلی‌های پروژه NHSample1 ، FluentNHibernate ، System.Data.SQLite ، NHibernate.ByteCode.Castle و Nhibernate نیز نیاز هستند). تمام اسمبلی‌های این فریم ورک‌ها از پروژه FluentNHibernate قابل استخراج هستند.

سپس سه کلاس زیر را به پروژه آزمون واحد اضافه خواهیم کرد.
کلاس TestModel : (جهت مشخص سازی محل دریافت اطلاعات نگاشت)

using FluentNHibernate;
using NHSample1.Domain;

namespace UnitTests
{
public class TestModel : PersistenceModel
{
public TestModel()
{
AddMappingsFromAssembly(typeof(CustomerMapping).Assembly);
}
}
}

کلاس FixtureBase : (جهت ایجاد سشن NHibernate در ابتدای آزمون واحد و سپس پاکسازی اشیاء در پایان کار)

using NUnit.Framework;
using NHibernate;
using FluentNHibernate;
using FluentNHibernate.Cfg;
using FluentNHibernate.Cfg.Db;

namespace UnitTests
{
public class FixtureBase
{
protected SessionSource SessionSource { get; set; }
protected ISession Session { get; private set; }

[SetUp]
public void SetupContext()
{
var cfg = Fluently.Configure().Database(SQLiteConfiguration.Standard.InMemory);

SessionSource = new SessionSource(
cfg.BuildConfiguration().Properties,
new TestModel());

Session = SessionSource.CreateSession();
SessionSource.BuildSchema(Session);
}

[TearDown]
public void TearDownContext()
{
Session.Close();
Session.Dispose();
}
}
}

و کلاس CustomerMapping_Fixture.cs : (جهت بررسی صحت نگاشت تهیه شده با کمک دو کلاس قبل)

using NUnit.Framework;
using FluentNHibernate.Testing;
using NHSample1.Domain;

namespace UnitTests
{
[TestFixture]
public class CustomerMapping_Fixture : FixtureBase
{
[Test]
public void can_correctly_map_customer()
{
new PersistenceSpecification<Customer>(Session)
.CheckProperty(c => c.Id, 1001)
.CheckProperty(c => c.FirstName, "Vahid")
.CheckProperty(c => c.LastName, "Nasiri")
.CheckProperty(c => c.AddressLine1, "Addr1")
.CheckProperty(c => c.AddressLine2, "Addr2")
.CheckProperty(c => c.PostalCode, "1234")
.CheckProperty(c => c.City, "Tehran")
.CheckProperty(c => c.CountryCode, "IR")
.VerifyTheMappings();
}
}
}

توضیحات:
اکنون به عنوان یک برنامه نویس متعهد نیاز است تا کار صورت گرفته در قسمت قبل را آزمایش کنیم.
کار بررسی صحت نگاشت تعریف شده در قسمت قبل توسط کلاس استاندارد PersistenceSpecification فریم ورک FluentNHibernate انجام خواهد شد (در کلاس CustomerMapping_Fixture). این کلاس برای انجام عملیات آزمون واحد نیاز به کلاس پایه دیگری به نام FixtureBase دارد که در آن کار ایجاد سشن NHibernate (در قسمت استاندارد SetUp آزمون واحد) و سپس آزاد سازی آن را در هنگام خاتمه کار ، انجام می‌دهد (در قسمت TearDown آزمون واحد).
این ویژگی‌ها که در مباحث آزمون واحد نیز به آن‌ها اشاره شده است، سبب اجرای متدهایی پیش از اجرا و بررسی هر آزمون واحد و سپس آزاد سازی خودکار منابع خواهند شد.
برای ایجاد یک سشن NHibernate نیاز است تا نوع دیتابیس و همچنین رشته اتصالی به آن (کانکشن استرینگ) مشخص شوند. فریم ورک Fluent NHibernate با ایجاد کلاس‌های کمکی برای این امر، به شدت سبب ساده‌ سازی انجام آن شده است. در این مثال، نوع دیتابیس به SQLite و در حالت دیتابیس در حافظه (in memory)، تنظیم شده است (برای انجام امور آزمون واحد با سرعت بالا).
جهت اجرای هر دستوری در NHibernate نیاز به یک سشن می‌باشد. برای تعریف شیء سشن، نه تنها نیاز به مشخص سازی نوع و حالت دیتابیس مورد استفاده داریم، بلکه نیاز است تا وهله‌ای از کلاس استاندارد PersistanceModel را نیز جهت مشخص سازی کلاس نگاشت مورد استفاده مشخص نمائیم. برای این منظور کلاس TestModel فوق تعریف شده است تا این نگاشت را از اسمبلی مربوطه بخواند و مورد استفاده قرار دهد (بر پایی اولیه این مراحل شاید در ابتدای امر کمی زمانبر باشد اما در نهایت یک پروسه استاندارد است). توسط این کلاس به سیستم اعلام خواهیم کرد که اطلاعات نگاشت را باید از کدام کلاس دریافت کند.
تا اینجای کار شیء SessionSource را با معرفی نوع دیتابیس و همچنین محل دریافت اطلاعات نگاشت اشیاء معرفی کردیم. در دو سطر بعدی متد SetupContext کلاس FixtureBase ، ابتدا یک سشن را از این منبع سشن تهیه می‌کنیم. شیء منبع سشن در این فریم ورک در حقیقت یک factory object است (الگوهای طراحی برنامه نویسی شیءگرا) که امکان دسترسی به انواع و اقسام دیتابیس‌ها را فراهم می‌سازد. برای مثال اگر روزی نیاز بود از دیتابیس اس کیوال سرور استفاده شود، می‌توان از کلاس MsSqlConfiguration بجای SQLiteConfiguration استفاده کرد و همینطور الی آخر.
در ادامه توسط شیء SessionSource کار ساخت database schema را نیز به صورت پویا انجام خواهیم داد. بله، همانطور که متوجه شده‌اید، کار ساخت database schema نیز به صورت پویا توسط فریم ورک NHibernate با توجه به اطلاعات کلاس‌های نگاشت، صورت خواهد گرفت.

این مراحل، نحوه ایجاد و بر پایی یک آزمایشگاه آزمون واحد فریم ورک Fluent NHibernate را مشخص ساخته و در پروژه‌های شما می‌توانند به کرات مورد استفاده قرار گیرند.

در ادامه اگر آزمون واحد را اجرا نمائیم (متد can_correctly_map_customer در کلاس CustomerMapping_Fixture)، نتیجه باید شبیه به شکل زیر باشد:



توسط متد CheckProperty کلاس PersistenceSpecification ، امکان بررسی نگاشت تهیه شده میسر است. اولین پارامتر آن، یک lambda expression خاصیت مورد نظر جهت بررسی است و دومین آرگومان آن، مقداری است که در حین آزمون به خاصیت تعریف شده انتساب داده می‌شود.

نکته:
شاید سؤال بپرسید که در تابع can_correctly_map_customer عملا چه اتفاقاتی رخ داده است؟ برای بررسی آن در متد SetupContext کلاس FixtureBase ، اولین سطر آن‌را به صورت زیر تغییر دهید تا عبارات SQL نهایی تولید شده را نیز بتوانیم در حین عملیات تست مشاهده نمائیم:

var cfg = Fluently.Configure().Database(SQLiteConfiguration.Standard.ShowSql().InMemory);




مطابق متد تست فوق، عبارات تولید شده به شرح زیر هستند:

NHibernate: select next_hi from hibernate_unique_key
NHibernate: update hibernate_unique_key set next_hi = @p0 where next_hi = @p1;@p0 = 2, @p1 = 1
NHibernate: INSERT INTO "Customer" (FirstName, LastName, AddressLine1, AddressLine2, PostalCode, City, CountryCode, Id) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7);@p0 = 'Vahid', @p1 = 'Nasiri', @p2 = 'Addr1', @p3 = 'Addr2', @p4 = '1234', @p5 = 'Tehran', @p6 = 'IR', @p7 = 1001
NHibernate: SELECT customer0_.Id as Id0_0_, customer0_.FirstName as FirstName0_0_, customer0_.LastName as LastName0_0_, customer0_.AddressLine1 as AddressL4_0_0_, customer0_.AddressLine2 as AddressL5_0_0_, customer0_.PostalCode as PostalCode0_0_, customer0_.City as City0_0_, customer0_.CountryCode as CountryC8_0_0_ FROM "Customer" customer0_ WHERE customer0_.Id=@p0;@p0 = 1001

نکته جالب این عبارات، استفاده از کوئری‌های پارامتری است به صورت پیش فرض که در نهایت سبب بالا رفتن امنیت بیشتر برنامه (یکی از راه‌های جلوگیری از تزریق اس کیوال در ADO.Net که در نهایت توسط تمامی این فریم ورک‌ها در پشت صحنه مورد استفاده قرار خواهند گرفت) و همچنین سبب بکار افتادن سیستم‌های کش دیتابیس‌های پیشرفته مانند اس کیوال سرور می‌شوند (execution plan کوئری‌های پارامتری در اس کیوال سرور جهت بالا رفتن کارآیی سیستم کش می‌شوند و اهمیتی هم ندارد که حتما رویه ذخیره شده باشند یا خیر).

ادامه دارد ...