566 lines
23 KiB
C#
566 lines
23 KiB
C#
using System;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.IO;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using AyaNova.Models;
|
|
using AyaNova.Api.ControllerHelpers;
|
|
using AyaNova.Util;
|
|
using AyaNova.Biz;
|
|
using System.Linq;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AyaNova.Api.Controllers
|
|
{
|
|
|
|
//FROM DOCS HERE:
|
|
//https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming
|
|
//https://github.com/aspnet/Docs/tree/74a44669d5e7039e2d4d2cb3f8b0c4ed742d1124/aspnetcore/mvc/models/file-uploads/sample/FileUploadSample
|
|
|
|
|
|
/// <summary>
|
|
/// Attachment controller
|
|
/// </summary>
|
|
[ApiController]
|
|
[ApiVersion("8.0")]
|
|
[Route("api/v{version:apiVersion}/[controller]")]
|
|
[Produces("application/json")]
|
|
public class AttachmentController : ControllerBase
|
|
{
|
|
private readonly AyContext ct;
|
|
private readonly ILogger<AttachmentController> log;
|
|
private readonly ApiServerState serverState;
|
|
//private static readonly FormOptions _defaultFormOptions = new FormOptions();
|
|
|
|
|
|
/// <summary>
|
|
///
|
|
/// </summary>
|
|
/// <param name="dbcontext"></param>
|
|
/// <param name="logger"></param>
|
|
/// <param name="apiServerState"></param>
|
|
public AttachmentController(AyContext dbcontext, ILogger<AttachmentController> logger, ApiServerState apiServerState)
|
|
{
|
|
ct = dbcontext;
|
|
log = logger;
|
|
serverState = apiServerState;
|
|
}
|
|
|
|
|
|
|
|
public class UpdateAttachmentInfo
|
|
{
|
|
[Required]
|
|
public uint ConcurrencyToken { get; set; }
|
|
[Required]
|
|
public string DisplayFileName { get; set; }
|
|
public string Notes { get; set; }
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Put (update) FileAttachment
|
|
/// (FileName and notes only)
|
|
/// </summary>
|
|
/// <param name="id"></param>
|
|
/// <param name="inObj"></param>
|
|
/// <returns>list</returns>
|
|
[Authorize]
|
|
[HttpPut("{id}")]
|
|
public async Task<IActionResult> PutAttachment([FromRoute] long id, [FromBody] UpdateAttachmentInfo inObj)
|
|
{
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
|
|
var dbObj = await ct.FileAttachment.SingleOrDefaultAsync(m => m.Id == id);
|
|
if (dbObj == null)
|
|
{
|
|
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
|
}
|
|
|
|
long UserId = UserIdFromContext.Id(HttpContext.Items);
|
|
|
|
if (!Authorized.HasModifyRole(HttpContext.Items, dbObj.AttachToObjectType))
|
|
{
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
}
|
|
|
|
|
|
try
|
|
{
|
|
string ChangeTextra = string.Empty;
|
|
if (dbObj.DisplayFileName != inObj.DisplayFileName)
|
|
{
|
|
ChangeTextra = $"\"{dbObj.DisplayFileName}\" => \"{inObj.DisplayFileName}\"";
|
|
}
|
|
if (dbObj.Notes != inObj.Notes)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(ChangeTextra))
|
|
ChangeTextra += ", ";
|
|
ChangeTextra += "Notes";
|
|
}
|
|
dbObj.DisplayFileName = inObj.DisplayFileName;
|
|
dbObj.Notes = inObj.Notes;
|
|
|
|
|
|
|
|
//Set "original" value of concurrency token to input token
|
|
//this will allow EF to check it out
|
|
ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = inObj.ConcurrencyToken;
|
|
|
|
//Log event and save context
|
|
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObj.AttachToObjectId, dbObj.AttachToObjectType, AyaEvent.AttachmentModified, ChangeTextra), ct);
|
|
|
|
|
|
|
|
//SEARCH INDEXING
|
|
var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationIdFromContext.Id(HttpContext.Items), id, AyaType.FileAttachment);
|
|
SearchParams.AddText(inObj.Notes).AddText(inObj.DisplayFileName);
|
|
await Search.ProcessUpdatedObjectKeywordsAsync(SearchParams);
|
|
|
|
//--------------
|
|
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT));
|
|
}
|
|
|
|
//Normallyh wouldn't return a whole list but in this case the UI demands it because of reactivity issues
|
|
var ret = await GetFileListForObjectAsync(dbObj.AttachToObjectType, dbObj.AttachToObjectId);
|
|
return Ok(ApiOkResponse.Response(ret, true));
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Get attachments for object type and id specified
|
|
///
|
|
/// Required Role: Read full object properties rights to object type specified
|
|
///
|
|
/// </summary>
|
|
/// <returns>file attachment list for object</returns>
|
|
[Authorize]
|
|
[HttpGet("List")]
|
|
public async Task<IActionResult> GetList([FromQuery] AyaType ayaType, [FromQuery] long ayaId)
|
|
{
|
|
if (serverState.IsClosed)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
if (!Authorized.HasReadFullRole(HttpContext.Items, ayaType))
|
|
{
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
var ret = await GetFileListForObjectAsync(ayaType, ayaId);
|
|
return Ok(ApiOkResponse.Response(ret, true));
|
|
}
|
|
|
|
|
|
//used to hold extra file data sent by client
|
|
public class fileData
|
|
{
|
|
public string name { get; set; }
|
|
public long lastModified { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Upload attachment file
|
|
///
|
|
/// Requires same Authorization roles as object that file is being attached to
|
|
///
|
|
/// </summary>
|
|
/// <returns>NameValue list of filenames and attachment id's</returns>
|
|
[Authorize]
|
|
[HttpPost]
|
|
[DisableFormValueModelBinding]
|
|
[RequestSizeLimit(10737418241)]//10737418240 = 10gb https://github.com/aspnet/Announcements/issues/267
|
|
public async Task<IActionResult> UploadAsync()
|
|
{
|
|
//Adapted from the example found here: https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming
|
|
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
// var returnList = new List<NameIdItem>();
|
|
object ret = null;
|
|
AyaTypeId attachToObject = null;
|
|
try
|
|
{
|
|
if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", $"Expected a multipart request, but got {Request.ContentType}"));
|
|
|
|
var uploadFormData = await ApiUploadProcessor.ProcessAttachmentUploadAsync(HttpContext);
|
|
|
|
bool badRequest = false;
|
|
string AttachToObjectType = string.Empty;
|
|
string AttachToObjectId = string.Empty;
|
|
string errorMessage = string.Empty;
|
|
string Notes = string.Empty;
|
|
List<fileData> FileData = new List<fileData>();
|
|
|
|
if (
|
|
!uploadFormData.FormFieldData.ContainsKey("FileData") ||
|
|
!uploadFormData.FormFieldData.ContainsKey("AttachToObjectType") ||
|
|
!uploadFormData.FormFieldData.ContainsKey("AttachToObjectId"))
|
|
{
|
|
badRequest = true;
|
|
errorMessage = "AttachToObjectType and / or AttachToObjectId are missing and are required";
|
|
}
|
|
if (!badRequest)
|
|
{
|
|
AttachToObjectType = uploadFormData.FormFieldData["AttachToObjectType"].ToString();
|
|
AttachToObjectId = uploadFormData.FormFieldData["AttachToObjectId"].ToString();
|
|
if (uploadFormData.FormFieldData.ContainsKey("Notes"))
|
|
Notes = uploadFormData.FormFieldData["Notes"].ToString();
|
|
//fileData in JSON stringify format which contains the actual last modified dates etc
|
|
//"[{\"name\":\"Client.csv\",\"lastModified\":1582822079618},{\"name\":\"wmi4fu06nrs41.jpg\",\"lastModified\":1586900220990}]"
|
|
FileData = Newtonsoft.Json.JsonConvert.DeserializeObject<List<fileData>>(uploadFormData.FormFieldData["FileData"].ToString());
|
|
|
|
if (string.IsNullOrWhiteSpace(AttachToObjectType) || string.IsNullOrWhiteSpace(AttachToObjectId))
|
|
{
|
|
badRequest = true;
|
|
errorMessage = "AttachToObjectType and / or AttachToObjectId are empty and are required";
|
|
}
|
|
}
|
|
|
|
|
|
//Get type and id object from post paramters
|
|
|
|
if (!badRequest)
|
|
{
|
|
attachToObject = new AyaTypeId(AttachToObjectType, AttachToObjectId);
|
|
if (attachToObject.IsEmpty)
|
|
{
|
|
badRequest = true;
|
|
errorMessage = "AttachToObjectType and / or AttachToObjectId are not valid and are required";
|
|
}
|
|
}
|
|
|
|
//Is it an attachable type of object?
|
|
if (!badRequest)
|
|
{
|
|
if (!attachToObject.IsCoreBizObject)
|
|
{
|
|
badRequest = true;
|
|
errorMessage = attachToObject.ObjectType.ToString() + " - AttachToObjectType does not support attachments";
|
|
}
|
|
}
|
|
|
|
|
|
//does attach to object exist?
|
|
if (!badRequest)
|
|
{
|
|
//check if object exists
|
|
if (!await BizObjectExistsInDatabase.ExistsAsync(attachToObject))
|
|
{
|
|
badRequest = true;
|
|
errorMessage = "Invalid attach object";
|
|
}
|
|
else
|
|
{
|
|
// User needs modify rights to the object type in question
|
|
if (!Authorized.HasModifyRole(HttpContext.Items, attachToObject.ObjectType))
|
|
{
|
|
//delete temp files
|
|
DeleteTempFileUploadDueToBadRequest(uploadFormData);
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (badRequest)
|
|
{
|
|
//delete temp files
|
|
DeleteTempFileUploadDueToBadRequest(uploadFormData);
|
|
//return bad request
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, errorMessage));
|
|
}
|
|
|
|
long UserId = UserIdFromContext.Id(HttpContext.Items);
|
|
|
|
//We have our files and a confirmed AyObject, ready to attach and save permanently
|
|
if (uploadFormData.UploadedFiles.Count > 0)
|
|
{
|
|
|
|
foreach (UploadedFileInfo a in uploadFormData.UploadedFiles)
|
|
{
|
|
//Get the actual date from the separate filedata
|
|
//this is because the lastModified date is always empty in the form data files
|
|
DateTime theDate = DateTime.MinValue;
|
|
foreach (fileData f in FileData)
|
|
{
|
|
if (f.name == a.OriginalFileName)
|
|
{
|
|
if (f.lastModified > 0)
|
|
{
|
|
theDate = DateTimeOffset.FromUnixTimeMilliseconds(f.lastModified).DateTime;
|
|
}
|
|
}
|
|
}
|
|
if (theDate == DateTime.MinValue)
|
|
theDate = DateTime.UtcNow;
|
|
|
|
var v = await FileUtil.StoreFileAttachmentAsync(a.InitialUploadedPathName, a.MimeType, a.OriginalFileName, theDate, attachToObject, Notes, ct);
|
|
|
|
//EVENT LOG
|
|
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, attachToObject.ObjectId, attachToObject.ObjectType, AyaEvent.AttachmentCreate, v.DisplayFileName), ct);
|
|
|
|
//SEARCH INDEXING
|
|
var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationIdFromContext.Id(HttpContext.Items), v.Id, AyaType.FileAttachment);
|
|
SearchParams.AddText(v.Notes).AddText(v.DisplayFileName);
|
|
await Search.ProcessNewObjectKeywordsAsync(SearchParams);
|
|
|
|
}
|
|
}
|
|
ret = await GetFileListForObjectAsync(attachToObject.ObjectType, attachToObject.ObjectId);
|
|
}
|
|
catch (InvalidDataException ex)
|
|
{
|
|
return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", ex.Message));
|
|
}
|
|
|
|
//Return the list of attachment ids and filenames
|
|
return Ok(ApiOkResponse.Response(ret, false));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Utility to delete files that were uploaded but couldn't be stored for some reason, called by Attach route
|
|
/// </summary>
|
|
/// <param name="uploadFormData"></param>
|
|
private static void DeleteTempFileUploadDueToBadRequest(ApiUploadProcessor.ApiUploadedFilesResult uploadFormData)
|
|
{
|
|
if (uploadFormData.UploadedFiles.Count > 0)
|
|
{
|
|
foreach (UploadedFileInfo a in uploadFormData.UploadedFiles)
|
|
{
|
|
System.IO.File.Delete(a.InitialUploadedPathName);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Delete Attachment
|
|
/// </summary>
|
|
/// <param name="id"></param>
|
|
/// <returns>Ok</returns>
|
|
[Authorize]
|
|
[HttpDelete("{id}")]
|
|
public async Task<IActionResult> DeleteAttachmentAsync([FromRoute] long id)
|
|
{
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return BadRequest(new ApiErrorResponse(ModelState));
|
|
}
|
|
|
|
var dbObj = await ct.FileAttachment.SingleOrDefaultAsync(m => m.Id == id);
|
|
if (dbObj == null)
|
|
{
|
|
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
|
}
|
|
|
|
long UserId = UserIdFromContext.Id(HttpContext.Items);
|
|
|
|
if (!Authorized.HasDeleteRole(HttpContext.Items, dbObj.AttachToObjectType))
|
|
{
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
}
|
|
|
|
//do the delete
|
|
//this handles removing the file if there are no refs left and also the db record for the attachment
|
|
await FileUtil.DeleteFileAttachmentAsync(dbObj, ct);
|
|
|
|
//Event log process delete
|
|
await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObj.AttachToObjectId, dbObj.AttachToObjectType, AyaEvent.AttachmentDelete, dbObj.DisplayFileName), ct);
|
|
|
|
//Delete search index
|
|
await Search.ProcessDeletedObjectKeywordsAsync(dbObj.Id, AyaType.FileAttachment);
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
/// Download a file attachment
|
|
/// </summary>
|
|
/// <param name="id"></param>
|
|
/// <param name="t">download token</param>
|
|
/// <returns></returns>
|
|
[HttpGet("download/{id}")]
|
|
public async Task<IActionResult> DownloadAsync([FromRoute] long id, [FromQuery] string t)
|
|
{
|
|
int nFailedAuthDelay = 3000;//should be just long enough to make brute force a hassle but short enough to not annoy people who just mistyped their creds to login
|
|
|
|
//NOTE this is the only unauthorized route as it needs to work with wiki url links and relies on the dlkey to work
|
|
//copied from Rockfish
|
|
//https://dotnetcoretutorials.com/2017/03/12/uploading-files-asp-net-core/
|
|
//https://stackoverflow.com/questions/45763149/asp-net-core-jwt-in-uri-query-parameter/45811270#45811270
|
|
if (!serverState.IsOpen)
|
|
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
|
|
|
|
|
|
//NOTE: this is a potentially dangerous route since it's not Authorized so we need to treat it like Auth route and not leak any
|
|
//useful information to bad actors and also ensure a delay to avoid brute force or DOS attacks
|
|
|
|
if (string.IsNullOrWhiteSpace(t))
|
|
{
|
|
await Task.Delay(nFailedAuthDelay);//DOS protection
|
|
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
|
}
|
|
|
|
|
|
|
|
|
|
//get user by key, if not found then reject
|
|
//If user dlkeyexp has not expired then return file
|
|
var DownloadUser = await ct.User.AsNoTracking().SingleOrDefaultAsync(m => m.DlKey == t && m.Active == true);
|
|
if (DownloadUser == null)
|
|
{
|
|
await Task.Delay(nFailedAuthDelay);//DOS protection
|
|
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
|
}
|
|
|
|
|
|
var utcNow = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero);
|
|
if (DownloadUser.DlKeyExpire < utcNow.DateTime)
|
|
{
|
|
|
|
await Task.Delay(nFailedAuthDelay);//DOS protection
|
|
return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED));
|
|
}
|
|
|
|
//Ok, user has a valid download key and it's not expired yet so get the attachment record
|
|
var dbObj = await ct.FileAttachment.SingleOrDefaultAsync(m => m.Id == id);
|
|
if (dbObj == null)
|
|
{
|
|
await Task.Delay(nFailedAuthDelay);//fishing protection
|
|
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
|
|
}
|
|
|
|
|
|
//is this allowed?
|
|
if (!Authorized.HasReadFullRole(DownloadUser.Roles, dbObj.AttachToObjectType))
|
|
{
|
|
await Task.Delay(nFailedAuthDelay);//DOS protection
|
|
return StatusCode(403, new ApiNotAuthorizedResponse());
|
|
}
|
|
|
|
//they are allowed, let's send the file
|
|
string mimetype = dbObj.ContentType;
|
|
var filePath = FileUtil.GetPermanentAttachmentFilePath(dbObj.StoredFileName);
|
|
if (!System.IO.File.Exists(filePath))
|
|
{
|
|
//TODO: this should trigger some kind of notification to the ops people
|
|
//and a red light on the dashboard
|
|
|
|
var errText = $"Physical file {dbObj.StoredFileName} not found despite attachment record, this file is missing";
|
|
log.LogError(errText);
|
|
|
|
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, null, errText));
|
|
}
|
|
|
|
//Log
|
|
await EventLogProcessor.LogEventToDatabaseAsync(new Event(DownloadUser.Id, dbObj.AttachToObjectId, dbObj.AttachToObjectType, AyaEvent.AttachmentDownload, dbObj.DisplayFileName), ct);
|
|
|
|
return PhysicalFile(filePath, mimetype, dbObj.DisplayFileName);
|
|
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
|
|
async private Task<object> GetFileListForObjectAsync(AyaType ayaType, long ayaId)
|
|
{
|
|
var l = await ct.FileAttachment.AsNoTracking().Where(m => m.AttachToObjectId == ayaId && m.AttachToObjectType == ayaType)
|
|
.Select(m => new { m.Id, m.ConcurrencyToken, m.ContentType, m.DisplayFileName, m.LastModified, m.Notes })
|
|
.ToArrayAsync();
|
|
var v = l.OrderBy(m => m.DisplayFileName);
|
|
return v;
|
|
}
|
|
|
|
}//eoc
|
|
}//eons
|
|
|
|
|
|
#region sample html form to work with this
|
|
/*
|
|
<!DOCTYPE html>
|
|
<html>
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title></title>
|
|
<script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
|
|
crossorigin="anonymous"></script>
|
|
<script type="text/javascript">
|
|
$(document).ready(function () {
|
|
$("#upload").click(function (evt) {
|
|
var fileUpload = $("#files").get(0);
|
|
var files = fileUpload.files;
|
|
var data = new FormData();
|
|
for (var i = 0; i < files.length; i++) {
|
|
data.append(files[i].name, files[i]);
|
|
}
|
|
|
|
//attachment test
|
|
data.append('AttachToObjectType','2');//object 2 is widget
|
|
data.append('AttachToObjectId','200');//there should normally always be a widget with id 1
|
|
|
|
$.ajax({
|
|
type: "POST",
|
|
url: "http://localhost:7575/api/v8.0/Attachment",
|
|
headers: {
|
|
Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNTIxNTY5ODE5IiwiZXhwIjoiMTUyNDE2MTgxOSIsImlzcyI6IkF5YU5vdmEiLCJpZCI6IjEifQ.4PzkzZNYK5mJkbfCHGQ2N2248atAvvcDgApoz65oIC0"
|
|
},
|
|
contentType: false,
|
|
processData: false,
|
|
data: data,
|
|
success: function (message) {
|
|
alert("upload successful!");
|
|
console.log(message);
|
|
},
|
|
error: function (error) {
|
|
console.log(error);
|
|
alert("There was an error uploading files!");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
</head>
|
|
|
|
<body>
|
|
<form method="post" enctype="multipart/form-data">
|
|
<input type="file" id="files" name="files" multiple />
|
|
<!-- <input type="file" accept=".zip,application/zip" id="files" name="files" multiple /> -->
|
|
<input type="button" id="upload" value="Upload file(s)" />
|
|
|
|
|
|
|
|
|
|
</form>
|
|
|
|
</body>
|
|
|
|
</html>
|
|
|
|
*/
|
|
#endregion |