18 Mei 202413
Pada tutorial ini, kita akan membuat dua fitur yang lazim digunakan pada sebuah aplikasi web yaitu fitur untuk mengunggah file dan fitur untuk notifikasi email.
Fitur upload file akan menggunakan
cloudlfare R2 bucket. R2 Bucket ini merupakan cloud storage yang bisa kita gunakan via
aws-sdk-net
, proses yang sama seperti AWS S3. Kemudian, cloud storage ini bisa digunakan secara gratis (free tier)
dengan kapasitas yang cukup besar yaitu 10GB per bulan.
Fitur email pada tutorial ini akan menggunakan MailerSend. MailerSend merupakan email service yang bisa kita gunakan untuk melakukan test pada saat development karena MailerSend menyediakan kuota 3000 email gratis per bulan.
Okay, mari kita buat dua fitur ini.
Pertama, kita buat terlebih dahulu dua file pada folder Pages
yaitu FileUpload.cshtml
dan
FileUpload.cshtml.cs
. Nantinya, kita bisa mengakses halaman dan melakukan upload file pada
url /FileUpload
.
Lalu, buka Pages/Shared/_Layout.cshtml
dan tambahkan kode berikut agar terdapat menu file upload
pada navbar.
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/FileUpload">File Upload</a>
</li>
Menu pada navbar akan menjadi seperti ini.
...
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
</li>
// Penambahan Menu File Upload
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/FileUpload">File Upload</a>
</li>
@{
// Penambahan Menu User Management
}
@if (User.IsInRole("Admin"))
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/UserManagement/Index">User
Management</a>
</li>
}
</ul>
...
Langkah berikutnya, pada file FileUpload.cshtml
tambahkan kode berikut.
@page
@model BoilerplateWebApp.Pages.FileUploadModel
@{
ViewData["Title"] = "File Upload";
}
<h1>File Upload</h1>
<form method="post" enctype="multipart/form-data">
<div class="form-group">
<div class="col-md-10">
<p>Upload one or more files using this form:</p>
<input type="file" name="files" multiple required/>
</div>
</div>
<div class="form-group mt-2">
<div class="col-md-10">
<input type="submit" value="Upload" />
</div>
</div>
</form>
<table class="table mt-3">
<thead>
<tr>
<th>
Id
</th>
<th>
Filename
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.UploadedFilesList)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.Id)
</td>
<td>
@Html.DisplayFor(modelItem => item.FileName)
</td>
<td>
<a asp-page="./FileUpload" asp-route-id="@item.Id">Download</a>
</td>
</tr>
}
</tbody>
</table>
Informasi mengenai file yang sudah diunggah serta input untuk mengunggah file terdapat pada pada file ini.
Model.UploadedFilesList
merupakan page model untuk informasi terkait file. Kemudian terdapat juga tombol
download file.
Lalu mari kita install AWS SDK untuk .NET agar bisa berinteraksi dengan R2 Bucket melalui terminal.
dotnet add package AWSSDK.S3 --version 3.7.400
Langkah selanjutnya, tambahkan kode berikut pada file FileUpload.cshtml.cs
.
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using BoilerplateWebApp.Data;
using BoilerplateWebApp.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace BoilerplateWebApp.Pages
{
[Authorize]
public class FileUploadModel(ApplicationDbContext context, IConfiguration config) : PageModel
{
private static AmazonS3Client? S3Client;
private readonly string UploadedFileBucketName = config["BucketName"]!;
private readonly string SecretKey = config["SecretKey"]!;
private readonly string AccessKey = config["AccessKey"]!;
private readonly string AccountID = config["AccountID"]!;
public List<UploadedFiles> UploadedFilesList = [];
public async Task<IActionResult> OnGetAsync(Guid? id)
{
if (id.HasValue)
{
return await DownloadFile(id.Value);
}
// List file
UploadedFilesList = await context.UploadedFiles.ToListAsync();
return Page();
}
public async Task<IActionResult> OnPostAsync(List<IFormFile> files)
{
long size = files.Sum(f => f.Length);
InitializeS3Client();
var uploadedFiles = new List<string>();
foreach (var formFile in files)
{
if (formFile.Length > 0)
{
await Console.Out.WriteLineAsync(formFile.FileName);
// Upload ke R2 Cloudflare
var fileName = Path.GetFileName(formFile.FileName);
var fileExtension = Path.GetExtension(fileName);
// Membuat unique filename untuk mencegah overwrite file dengan nama yang sama
var uniqueFileName = $"{Guid.NewGuid()}{fileExtension}";
using var memoryStream = new MemoryStream();
await formFile.CopyToAsync(memoryStream);
memoryStream.Position = 0;
var putRequest = new PutObjectRequest
{
BucketName = UploadedFileBucketName,
Key = uniqueFileName,
InputStream = memoryStream,
ContentType = formFile.ContentType,
DisablePayloadSigning = true
};
try
{
var response = await S3Client!.PutObjectAsync(putRequest);
uploadedFiles.Add(uniqueFileName);
var uploadedFile = new UploadedFiles
{
Id = Guid.NewGuid(),
FileName = formFile.FileName,
FileUrl = uniqueFileName,
};
context.UploadedFiles.Add(uploadedFile);
await context.SaveChangesAsync();
Console.WriteLine($"File uploaded successfully: {uniqueFileName}");
}
catch (AmazonS3Exception ex)
{
Console.WriteLine($"Error uploading file {uniqueFileName}: {ex.Message}");
}
}
}
return Redirect("/FileUpload");
}
private void InitializeS3Client()
{
var accessKey = AccessKey;
var secretKey = SecretKey;
var credentials = new BasicAWSCredentials(accessKey, secretKey);
S3Client = new AmazonS3Client(credentials, new AmazonS3Config
{
ServiceURL = $"https://{AccountID}.r2.cloudflarestorage.com",
});
}
private async Task<IActionResult> DownloadFile(Guid id)
{
var file = await context.UploadedFiles.FindAsync(id);
if (file == null)
{
return NotFound($"File with ID {id} not found.");
}
InitializeS3Client();
try
{
var request = new GetObjectRequest
{
BucketName = UploadedFileBucketName,
Key = file.FileUrl // Assuming FileUrl contains the S3 object key
};
using var response = await S3Client!.GetObjectAsync(request);
using var responseStream = response.ResponseStream;
using var memoryStream = new MemoryStream();
await responseStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
return File(memoryStream.ToArray(), response.Headers.ContentType, file.FileName);
}
catch (AmazonS3Exception ex)
{
await Console.Out.WriteLineAsync(ex.Message);
return RedirectToPage();
}
}
}
}
FileUpload.cshtml.cs
merupakan proses dimana upload file berlangsung nantinya. OnGetAsync
akan
memunculkan daftar file yang telah diunggah. OnPostAsync
melakukan proses untuk menyimpan file pada
R2 Bucket dan database. Nama file diubah menjadi unik (uniqueFileName
) agar tidak terjadi overwrite karena ada kesamaan nama file.
InitializeS3Client()
merupakan function untuk berinteraksi dengan R2 Bucket
melalui AWS SDK. Kemudian DownloadFile
akan bejalan ketika user melakukan download file.
Setelah itu, mari kita buat model untuk file upload. Buat sebuah folder yaitu Model
dan file
baru di dalamnya yaitu UploadedFilesModel.cs
. Tambahkan kode berikut pada file tersebut.
namespace BoilerplateWebApp.Models
{
public class UploadedFilesModel
{
public Guid Id { get; set; }
public string FileName { get; set; } = string.Empty;
public string FileUrl { get; set; } = string.Empty;
}
}
Model ini merupakan struktur informasi yang akan kita simpan pada database. Buka file Data/ApplicationDBCOntext.cs
lalu ubah file menjadi seperti ini.
using BoilerplateWebApp.Models;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace BoilerplateWebApp.Data;
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<UploadedFilesModel> UploadedFiles { get; set; }
}
Kita menambahkan line public DbSet<UploadedFilesModel> UploadedFiles { get; set; }
agar informasi
mengenai file yang sudah diunggah akan tersimpan di database.
Selanjutnya, kita harus melakukan update pada database agar terdapat table untuk informasi mengenai
file yang sudah diunggah. Sebelumnya, pastikan dotnet-ef
sudah terinstall. Apabila belum terdapat dotnet-ef
, jalankan
perintah ini untuk install dotnet-ef
melalui terminal.
dotnet tool install --global dotnet-ef
Lalu, kita buat sebuah migration dengan perintah ini pada terminal.
dotnet ef migrations add AddUploadedFilesTable
Langkah selanjutnya adalah update database kita dengan menjalankan ini.
dotnet ef database update
Jika terdapat error, kawan-kawan bisa melakukan build secara clean terlebih dahulu dengan perintah ini, lalu mencoba lagi dengan membuat migration dan menjalankan database.
dotnet clean && dotnet restore && dotnet build
Proses berikutnya adalah melakukan setting pada Cloudflare R2 Bucket. Kita akan menggunakan
informasi Account ID
, Access Key ID
, dan Secret Key ID
agar kita bisa berinteraksi dengan R2 Bucket
melalui AWS SDK.
Untuk melakukan setting pada R2 teman-teman bisa mengikuti langkah berikut:
boilerplate-aspdotnet-core
(teman-teman juga bisa menggunakan nama yang lain).Account ID
yang ada di dashboard.boilerplate-aspdotnet-core-token
dan set permissions menjadi Admin Read & Write
.Access Key ID
dan Secret Access Key
. Nantinya, informasi ini akan kita gunakan bersama Account ID
(Langkah keempat).Setelah setting R2, mari kita tambahkan informasi Account ID
, Access Key ID
, dan Secret Key ID
pada project kita melalui terminal dengan perintah berikut ini. Informasi ini hanya digunakan pada saat
proses development saja. Untuk versi production, kita bisa gunakan metode lain untuk menyimpan informasi ini (dibahas pada tutorial bagian 5).
dotnet user-secrets set "BucketName" "boilerplate-aspdotnet-core" && \
dotnet user-secrets set "SecretKey" "<Ganti sesuai Secret Access Key>" && \
dotnet user-secrets set "AccessKey" "<Ganti sesuai Access Key ID>" && \
dotnet user-secrets set "AccountID" "<Ganti sesuai Account ID>"
Mari kita coba fitur upload ini dengan menjalankan aplikasinya.
dotnet watch
Silakan teman-teman mengunjungi /FileUpload
dan melakukan proses upload file. Setelah upload, file
akan muncul pada daftar yang di halaman File Upload. Ketika teman-teman klik download, proses
download akan langsung berjalan. Kemudian, file yang telah diunggah bisa teman-teman lihat di Dashboard Cloudflare R2.
Berikut ini merupakan contoh file-file yang sudah diunggah ke R2 Bucket melalui aplikasi ini.
Mari kita setting MailerSend sebagai email service aplikasi ini.
boilerplate-email-dev
serta pilih permission level Full Access
.Selanjutnya, mari kita tambahkan informasi ini sebagai user-secrets
pada project ini melalui terminal
dotnet user-secrets set "MailerSendToken" <Ganti sesuai MailerSend API Token>
Langkah berikutnya adalah integrasi email service ini pada proses registrasi user. Saat ini, aplikasi kita akan melakukan redirect ke halaman Register confirmation setelah user melakukan registrasi. Kita akan menonaktifkan halaman ini sehingga user hanya bisa mengaktifkan akunnya dengan mengunjungi verification link yang dikirmkan melalui email.
Buka file Areas\Identity\Pages\Account\RegisterConfirmation.cshtml.cs
. Ubah pada kode di line 63 dengan mengganti value
DisplayConfirmAccountLink
dari true
menjadi false
.
// Once you add a real email sender, you should remove this code that lets you confirm the account
DisplayConfirmAccountLink = false; // Mengubah value menjadi false
Setelah itu, tambahkan function berikut ke dalam file Areas\Identity\Pages\Account\Register.cshtml.cs
.
private async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.mailersend.com/v1/email");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
request.Headers.Add("Authorization", $"Bearer {_configuration["MailerSendToken"]}");
var emailData = new
{
from = new { email = "[email protected]" },
to = new[] { new { email = email } },
subject = subject,
text = htmlMessage,
html = htmlMessage
};
var json = JsonSerializer.Serialize(emailData);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
}
Kemudian pada file yang sama, ubah function OnPostAsync
dan tambahkan kode berikut.
...
// kode sebelumnya
// await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
// $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
// Penggunaan MailerSend
await SendEmailAsync(Input.Email, "Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
if (_userManager.Options.SignIn.RequireConfirmedAccount)
{
return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
}
else
{
await _signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl);
}
}
Berikut hasil akhir file Areas\Identity\Pages\Account\Register.cshtml.cs
.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
namespace BoilerplateWebApp.Areas.Identity.Pages.Account
{
public class RegisterModel : PageModel
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly IUserStore<IdentityUser> _userStore;
private readonly IUserEmailStore<IdentityUser> _emailStore;
private readonly ILogger<RegisterModel> _logger;
private readonly IEmailSender _emailSender;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
public RegisterModel(
UserManager<IdentityUser> userManager,
IUserStore<IdentityUser> userStore,
SignInManager<IdentityUser> signInManager,
ILogger<RegisterModel> logger,
IEmailSender emailSender,
IHttpClientFactory httpClientFactory,
IConfiguration configuration)
{
_userManager = userManager;
_userStore = userStore;
_emailStore = GetEmailStore();
_signInManager = signInManager;
_logger = logger;
_emailSender = emailSender;
_httpClientFactory = httpClientFactory;
_configuration = configuration;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<AuthenticationScheme> ExternalLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public async Task OnGetAsync(string returnUrl = null)
{
ReturnUrl = returnUrl;
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
var user = CreateUser();
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await _userManager.CreateAsync(user, Input.Password);
await _userManager.AddToRoleAsync(user, "User");
if (result.Succeeded)
{
_logger.LogInformation("User created a new account with password.");
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
protocol: Request.Scheme);
// await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
// $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
await SendEmailAsync(Input.Email, "Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
if (_userManager.Options.SignIn.RequireConfirmedAccount)
{
return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
}
else
{
await _signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl);
}
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
// If we got this far, something failed, redisplay form
return Page();
}
private IdentityUser CreateUser()
{
try
{
return Activator.CreateInstance<IdentityUser>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(IdentityUser)}'. " +
$"Ensure that '{nameof(IdentityUser)}' is not an abstract class and has a parameterless constructor, or alternatively " +
$"override the register page in /Areas/Identity/Pages/Account/Register.cshtml");
}
}
private IUserEmailStore<IdentityUser> GetEmailStore()
{
if (!_userManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<IdentityUser>)_userStore;
}
private async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
var client = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.mailersend.com/v1/email");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
request.Headers.Add("Authorization", $"Bearer {_configuration["MailerSendToken"]}");
var emailData = new
{
from = new { email = "[email protected]" },
to = new[] { new { email = email } },
subject = subject,
text = htmlMessage,
html = htmlMessage
};
var json = JsonSerializer.Serialize(emailData);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
}
}
}
SendEmailAsync
berisi proses untuk mengirimkan email ke API MailerSend melalui HTTP POST
ke
endpoint https://api.mailersend.com/v1/email
.
Kemudian, buka Program.cs
dan tambahkan kode HTTP Client
.
...
builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>() // Menambahkan role services
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();
builder.Services.AddHttpClient(); // Menambahkan HTTP Client
var app = builder.Build();
...
Okay, sekarang mari kita jalankan aplikasi ini.
dotnet watch
Teman-teman bisa menuju halaman register dan tambahkan user baru. Setelah proses registrasi selesai, akan muncul halaman Register Confirmation serta email yang sudah dikirimkan ke user.
Berikut contoh email yang dikirimkan kepada user.
Ketika user klik link yang ada pada email, maka user tersebut diaktifkan akunnya sehingga bisa melakukan login pada aplikasi.
Saat ini boilerplate ini sudah mempunyai dua fitur tambahan yaitu upload file dan notifikasi email. Kedua fitur ini merupakan fitur esensial pada sebuah aplikasi. Teman-teman bisa melakukan proses lain dengan menggunakan kode pada tutorial ini misalnya menambahkan fitur user profile picture, forgot password, atau melakukan notifikasi aktivitas pada user melalui email.
Pada bagian selanjutnya, kita akan melakukan proses akhir yaitu deployment untuk aplikasi sehingga aplikasi boilerplate ini menjadi siap untuk dirilis kepada user nantinya.
Happy Programming!