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 Sockeye.Models; using Sockeye.Api.ControllerHelpers; using Sockeye.Util; using Sockeye.Biz; using System.Linq; using System.Collections.Generic; namespace Sockeye.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 /// /// Attachment controller /// [ApiController] [ApiVersion("8.0")] [Route("api/v{version:apiVersion}/attachment")] [Produces("application/json")] public class AttachmentController : ControllerBase { private readonly AyContext ct; private readonly ILogger log; private readonly ApiServerState serverState; /// /// /// /// /// /// public AttachmentController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) { ct = dbcontext; log = logger; serverState = apiServerState; } public class UpdateAttachmentInfo { [Required] public uint Concurrency { get; set; } [Required] public string DisplayFileName { get; set; } public string Notes { get; set; } } /// /// Update FileAttachment /// (FileName and notes only) /// /// /// /// list [Authorize] [HttpPut("{id}")] public async Task 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 dbObject = await ct.FileAttachment.SingleOrDefaultAsync(z => z.Id == id); if (dbObject == null) { return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); } long UserId = UserIdFromContext.Id(HttpContext.Items); if (!Authorized.HasModifyRole(HttpContext.Items, dbObject.AttachToAType)) { return StatusCode(403, new ApiNotAuthorizedResponse()); } try { string ChangeTextra = string.Empty; if (dbObject.DisplayFileName != inObj.DisplayFileName) { ChangeTextra = $"\"{dbObject.DisplayFileName}\" => \"{inObj.DisplayFileName}\""; } if (dbObject.Notes != inObj.Notes) { if (!string.IsNullOrWhiteSpace(ChangeTextra)) ChangeTextra += ", "; ChangeTextra += "Notes"; } dbObject.DisplayFileName = inObj.DisplayFileName; dbObject.Notes = inObj.Notes; //Set "original" value of concurrency token to input token //this will allow EF to check it out ct.Entry(dbObject).OriginalValues["Concurrency"] = inObj.Concurrency; //Log event and save context await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.AttachToObjectId, dbObject.AttachToAType, SockEvent.AttachmentModified, ChangeTextra), ct); //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationIdFromContext.Id(HttpContext.Items), id, SockType.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(dbObject.AttachToAType, dbObject.AttachToObjectId); return Ok(ApiOkResponse.Response(ret)); } /// /// Get attachments for object type and id specified /// /// Required Role: Read full object properties rights to object type specified /// /// /// file attachment list for object [Authorize] [HttpGet("list")] public async Task GetList([FromQuery] SockType sockType, [FromQuery] long sockId) { if (serverState.IsClosed) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); //Is this a customer user attempting to view a wo attachments?? // var userType = UserTypeFromContext.Type(HttpContext.Items); if (!Authorized.HasReadFullRole(HttpContext.Items, sockType)) { return StatusCode(403, new ApiNotAuthorizedResponse()); } var ret = await GetFileListForObjectAsync(sockType, sockId); return Ok(ApiOkResponse.Response(ret)); } /// /// Get parent object type and id /// for specified attachment id /// /// /// [Authorize] [HttpGet("parent/{id}")] public async Task GetParent([FromRoute] long id) { if (serverState.IsClosed) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!Authorized.HasReadFullRole(HttpContext.Items, SockType.FileAttachment)) return StatusCode(403, new ApiNotAuthorizedResponse()); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); var at = await ct.FileAttachment.AsNoTracking().Where(z => z.Id == id).FirstOrDefaultAsync(); if (at == null) return NotFound(); return Ok(ApiOkResponse.Response(new { id = at.AttachToObjectId, type = at.AttachToAType })); } /// /// Upload attachment file /// Max 10GiB total /// Requires same Authorization roles as object that file is being attached to /// /// /// NameValue list of filenames and attachment id's [Authorize] [HttpPost] [DisableFormValueModelBinding] [RequestSizeLimit(ServerBootConfig.MAX_ATTACHMENT_UPLOAD_BYTES)] public async Task 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(); object ret = null; SockTypeId attachToObject = null; try { if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType)) return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, $"Expected a multipart request, but got {Request.ContentType}")); bool badRequest = false; string AttachToAType = string.Empty; string AttachToObjectId = string.Empty; string errorMessage = string.Empty; string Notes = string.Empty; long? OverrideUserId = null; List FileData = new List(); var uploadFormData = await ApiUploadProcessor.ProcessUploadAsync(HttpContext); if (!string.IsNullOrWhiteSpace(uploadFormData.Error)) { badRequest = true; errorMessage = uploadFormData.Error; } if (!badRequest && (!uploadFormData.FormFieldData.ContainsKey("FileData") || !uploadFormData.FormFieldData.ContainsKey("AttachToAType") || !uploadFormData.FormFieldData.ContainsKey("AttachToObjectId"))) { badRequest = true; errorMessage = "Missing one or more required FormFieldData values: AttachToAType, AttachToObjectId, FileData"; } if (!badRequest) { AttachToAType = uploadFormData.FormFieldData["AttachToAType"].ToString(); //for v8 migrate purposes if (uploadFormData.FormFieldData.ContainsKey("OverrideUserId")) OverrideUserId = long.Parse(uploadFormData.FormFieldData["OverrideUserId"].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>(uploadFormData.FormFieldData["FileData"].ToString()); if (string.IsNullOrWhiteSpace(AttachToAType) || string.IsNullOrWhiteSpace(AttachToObjectId)) { badRequest = true; errorMessage = "AttachToAType and / or AttachToObjectId are empty and are required"; } } //Get type and id object from post paramters if (!badRequest) { attachToObject = new SockTypeId(AttachToAType, AttachToObjectId); if (attachToObject.IsEmpty) { badRequest = true; errorMessage = "AttachToAType 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.SockType.ToString() + " - AttachToAType does not support attachments"; } } //does attach to object exist? if (!badRequest) { //check if object exists if (!await BizObjectExistsInDatabase.ExistsAsync(attachToObject.SockType, attachToObject.ObjectId, ct)) { badRequest = true; errorMessage = "Invalid attach object"; } else { // User needs modify rights to the object type in question if (!Authorized.HasModifyRole(HttpContext.Items, attachToObject.SockType)) { //delete temp files ApiUploadProcessor.DeleteTempUploadFile(uploadFormData); return StatusCode(403, new ApiNotAuthorizedResponse()); } } } if (badRequest) { //delete temp files ApiUploadProcessor.DeleteTempUploadFile(uploadFormData); //file too large is most likely issue so in that case return this localized properly if (errorMessage.Contains("413")) { var TransId = UserTranslationIdFromContext.Id(HttpContext.Items); return BadRequest(new ApiErrorResponse( ApiErrorCode.VALIDATION_LENGTH_EXCEEDED, null, "HTTP ERROR CODE 413 " + String.Format(await TranslationBiz.GetTranslationStaticAsync("AyaFileFileTooLarge", TransId, ct), FileUtil.GetBytesReadable(ServerBootConfig.MAX_ATTACHMENT_UPLOAD_BYTES)))); } else//not too big, something else 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 (UploadFileData f in FileData) { if (f.name == a.OriginalFileName) { if (f.lastModified > 0) { theDate = DateTimeOffset.FromUnixTimeMilliseconds(f.lastModified).UtcDateTime; } } } if (theDate == DateTime.MinValue) theDate = DateTime.UtcNow; var v = await FileUtil.StoreFileAttachmentAsync(a.InitialUploadedPathName, a.MimeType, a.OriginalFileName, theDate, attachToObject, Notes, OverrideUserId ?? UserId, ct); //EVENT LOG await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, attachToObject.ObjectId, attachToObject.SockType, SockEvent.AttachmentCreate, v.DisplayFileName), ct); //SEARCH INDEXING var SearchParams = new Search.SearchIndexProcessObjectParameters(UserTranslationIdFromContext.Id(HttpContext.Items), v.Id, SockType.FileAttachment); SearchParams.AddText(v.Notes).AddText(v.DisplayFileName); await Search.ProcessNewObjectKeywordsAsync(SearchParams); } } ret = await GetFileListForObjectAsync(attachToObject.SockType, attachToObject.ObjectId); } catch (InvalidDataException ex) { return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, ex.Message)); } //Return the list of attachment ids and filenames return Ok(ApiOkResponse.Response(ret)); } // /// // /// Utility to delete files that were uploaded but couldn't be stored for some reason, called by Attach route // /// // /// // private static void DeleteTempFileUploadDueToBadRequest(ApiUploadProcessor.ApiUploadedFilesResult uploadFormData) // { // if (uploadFormData.UploadedFiles.Count > 0) // { // foreach (UploadedFileInfo a in uploadFormData.UploadedFiles) // { // System.IO.File.Delete(a.InitialUploadedPathName); // } // } // } /// /// Delete Attachment /// /// /// Ok [Authorize] [HttpDelete("{id}")] public async Task 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 dbObject = await ct.FileAttachment.SingleOrDefaultAsync(z => z.Id == id); if (dbObject == null) { return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); } long UserId = UserIdFromContext.Id(HttpContext.Items); if (!Authorized.HasDeleteRole(HttpContext.Items, dbObject.AttachToAType)) { 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(dbObject, ct); //Event log process delete await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.AttachToObjectId, dbObject.AttachToAType, SockEvent.AttachmentDelete, dbObject.DisplayFileName), ct); //Delete search index await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, SockType.FileAttachment, ct); return NoContent(); } /// /// Batch delete attachments /// /// /// No content [HttpPost("batch-delete")] [Authorize] public async Task PostBatchDelete([FromBody] List idList) { if (serverState.IsClosed) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); if (!Authorized.HasModifyRole(HttpContext.Items, SockType.FileAttachment)) return StatusCode(403, new ApiNotAuthorizedResponse()); long UserId = UserIdFromContext.Id(HttpContext.Items); foreach (long id in idList) { var dbObject = await ct.FileAttachment.FirstOrDefaultAsync(z => z.Id == id); if (dbObject == null) continue; //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(dbObject, ct); //Event log process delete await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.AttachToObjectId, dbObject.AttachToAType, SockEvent.AttachmentDelete, dbObject.DisplayFileName), ct); //Delete search index await Search.ProcessDeletedObjectKeywordsAsync(dbObject.Id, SockType.FileAttachment, ct); } return NoContent(); } /// /// Batch move attachments /// /// /// No content [HttpPost("batch-move")] [Authorize] public async Task PostBatchMove([FromBody] dtoBatchMove dt) { if (serverState.IsClosed) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!ModelState.IsValid) return BadRequest(new ApiErrorResponse(ModelState)); if (!Authorized.HasModifyRole(HttpContext.Items, SockType.FileAttachment)) return StatusCode(403, new ApiNotAuthorizedResponse()); if (!await BizObjectExistsInDatabase.ExistsAsync(dt.ToType, dt.ToId, ct)) return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, null, "LT:ErrorAPI2010")); long UserId = UserIdFromContext.Id(HttpContext.Items); foreach (long id in dt.IdList) { var dbObject = await ct.FileAttachment.FirstOrDefaultAsync(z => z.Id == id); if (dbObject == null) continue; //do the move var msg = $"{dbObject.DisplayFileName} moved from {dbObject.AttachToAType}-{dbObject.AttachToObjectId} to {dt.ToType}-{dt.ToId} "; dbObject.AttachToObjectId = dt.ToId; dbObject.AttachToAType = dt.ToType; await ct.SaveChangesAsync(); //Event log process move await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserId, dbObject.AttachToObjectId, dbObject.AttachToAType, SockEvent.AttachmentModified, msg), ct); } return NoContent(); } public class dtoBatchMove { public List IdList { get; set; } public SockType ToType { get; set; } public long ToId { get; set; } } /// /// Download a file attachment /// /// /// download token /// [HttpGet("download/{id}")] public async Task DownloadAsync([FromRoute] long id, [FromQuery] string t) { //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)); var DownloadUser = await UserBiz.ValidateDownloadTokenAndReturnUserAsync(t, ct); if (DownloadUser == null) { await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);//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 dbObject = await ct.FileAttachment.SingleOrDefaultAsync(z => z.Id == id); if (dbObject == null) { await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);//fishing protection return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); } //is this allowed? if (!Authorized.HasReadFullRole(DownloadUser.Roles, dbObject.AttachToAType)) { await Task.Delay(Sockeye.Util.ServerBootConfig.FAILED_AUTH_DELAY);//DOS protection return StatusCode(403, new ApiNotAuthorizedResponse()); } //they are allowed, let's send the file string mimetype = dbObject.ContentType; var filePath = FileUtil.GetPermanentAttachmentFilePath(dbObject.StoredFileName); if (!System.IO.File.Exists(filePath)) { //TODO: this should reset the validity var errText = $"Physical file {dbObject.StoredFileName} not found despite attachment record, this file is missing"; log.LogError(errText); await NotifyEventHelper.AddOpsProblemEvent($"File attachment issue: {errText}"); return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, null, errText)); } //Log await EventLogProcessor.LogEventToDatabaseAsync(new Event(DownloadUser.Id, dbObject.AttachToObjectId, dbObject.AttachToAType, SockEvent.AttachmentDownload, dbObject.DisplayFileName), ct); return PhysicalFile(filePath, mimetype, dbObject.DisplayFileName); } //////////////////////////////////////////////////////////////////////////////////// async private Task GetFileListForObjectAsync(SockType sockType, long sockId) { var retList = new List(); using (var cmd = ct.Database.GetDbConnection().CreateCommand()) { await ct.Database.OpenConnectionAsync(); cmd.CommandText = $@"select afileattachment.id, afileattachment.xmin as concurrency, displayfilename,contenttype,lastmodified, afileattachment.notes, size, auser.name as attachedbyuser from afileattachment left join auser on (afileattachment.attachedByUserId=auser.id) where attachtoatype={(int)sockType} and attachtoobjectid={sockId} order by displayfilename"; using (var dr = await cmd.ExecuteReaderAsync()) { while (dr.Read()) { retList.Add(new FileAttachmentListItem() { Id = dr.GetInt64(0), Concurrency = (UInt32)dr.GetValue(1), DisplayFileName = dr.GetString(2), ContentType = dr.GetString(3), LastModified = dr.GetDateTime(4), Notes = dr.GetString(5), Size = dr.GetInt64(6), AttachedByUser = dr.GetString(7) }); } } } return retList; } private class FileAttachmentListItem { public long Id { get; set; } public uint Concurrency { get; set; } public string DisplayFileName { get; set; } public string ContentType { get; set; }//mime type public DateTime LastModified { get; set; } public string Notes { get; set; } public string AttachedByUser { get; set; } public long Size { get; set; } } /// /// Trigger immediate AttachmentMaintenanceJob /// /// /// Job Id [HttpPost("maintenance")] [Authorize] public async Task PostTriggerAttachmentMaintenanceJob() { if (serverState.IsClosed) return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); if (!Authorized.HasModifyRole(HttpContext.Items, SockType.FileAttachment)) return StatusCode(403, new ApiNotAuthorizedResponse()); var JobName = $"Attachment maintenance (demand) LT:User {UserNameFromContext.Name(HttpContext.Items)}"; OpsJob j = new OpsJob(); j.Name = JobName; j.SockType = SockType.FileAttachment; j.JobType = JobType.AttachmentMaintenance; j.SubType = JobSubType.NotSet; j.Exclusive = true; await JobsBiz.AddJobAsync(j); await EventLogProcessor.LogEventToDatabaseAsync(new Event(UserIdFromContext.Id(HttpContext.Items), 0, SockType.ServerJob, SockEvent.Created, JobName), ct); return Accepted(new { JobId = j.GId }); } }//eoc }//eons