commit
eb35c959df
@ -0,0 +1,52 @@ |
||||
require_relative 'fog_file_uploader' |
||||
|
||||
class DirectFogUploader < FogFileUploader |
||||
include CarrierWaveDirect::Uploader |
||||
|
||||
def self.for_attachment(attachment) |
||||
for_uploader attachment.file |
||||
end |
||||
|
||||
def self.for_uploader(fog_file_uploader) |
||||
raise ArgumentError, "FogFileUploader expected" unless fog_file_uploader.is_a? FogFileUploader |
||||
|
||||
uploader = self.new |
||||
|
||||
uploader.instance_variable_set "@file", fog_file_uploader.file |
||||
uploader.instance_variable_set "@key", fog_file_uploader.path |
||||
|
||||
uploader |
||||
end |
||||
|
||||
## |
||||
# Generates the direct upload form for the given attachment. |
||||
# |
||||
# @param attachment [Attachment] The attachment for which a file is to be uploaded. |
||||
# @param success_action_redirect [String] URL to redirect to if successful (none by default, using status). |
||||
# @param success_action_status [String] The HTTP status to return on success (201 by default). |
||||
# @param max_file_size [Integer] The maximum file size to be allowed in bytes. |
||||
def self.direct_fog_hash( |
||||
attachment:, |
||||
success_action_redirect: nil, |
||||
success_action_status: "201", |
||||
max_file_size: Setting.attachment_max_size * 1024 |
||||
) |
||||
uploader = for_attachment attachment |
||||
|
||||
if success_action_redirect.present? |
||||
uploader.success_action_redirect = success_action_redirect |
||||
uploader.use_action_status = false |
||||
else |
||||
uploader.success_action_status = success_action_status |
||||
uploader.use_action_status = true |
||||
end |
||||
|
||||
hash = uploader.direct_fog_hash(enforce_utf8: false, max_file_size: max_file_size) |
||||
|
||||
if success_action_redirect.present? |
||||
hash.merge(success_action_redirect: success_action_redirect) |
||||
else |
||||
hash.merge(success_action_status: success_action_status) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,53 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 the OpenProject GmbH |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
class Attachments::FinishDirectUploadJob < ApplicationJob |
||||
queue_with_priority :high |
||||
|
||||
def perform(attachment_id) |
||||
attachment = Attachment.pending_direct_uploads.where(id: attachment_id).first |
||||
local_file = attachment && attachment.file.local_file |
||||
|
||||
if local_file.nil? |
||||
return Rails.logger.error("File for attachment #{attachment_id} was not uploaded.") |
||||
end |
||||
|
||||
begin |
||||
attachment.downloads = 0 |
||||
attachment.set_file_size local_file unless attachment.filesize && attachment.filesize > 0 |
||||
attachment.set_content_type local_file unless attachment.content_type.present? |
||||
attachment.set_digest local_file unless attachment.digest.present? |
||||
|
||||
attachment.save! if attachment.changed? |
||||
ensure |
||||
File.unlink(local_file.path) if File.exist?(local_file.path) |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,148 @@ |
||||
//-- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2020 the OpenProject GmbH
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See docs/COPYRIGHT.rdoc for more details.
|
||||
//++
|
||||
|
||||
import {Injectable} from "@angular/core"; |
||||
import {HttpEvent, HttpResponse} from "@angular/common/http"; |
||||
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; |
||||
import {from, Observable, of} from "rxjs"; |
||||
import {share, switchMap} from "rxjs/operators"; |
||||
import {OpenProjectFileUploadService, UploadBlob, UploadFile, UploadInProgress} from './op-file-upload.service'; |
||||
|
||||
interface PrepareUploadResult { |
||||
url:string; |
||||
form:FormData; |
||||
response:any; |
||||
} |
||||
|
||||
@Injectable() |
||||
export class OpenProjectDirectFileUploadService extends OpenProjectFileUploadService { |
||||
/** |
||||
* Upload a single file, get an UploadResult observable |
||||
* @param {string} url |
||||
* @param {UploadFile} file |
||||
* @param {string} method |
||||
*/ |
||||
public uploadSingle(url:string, file:UploadFile|UploadBlob, method:string = 'post', responseType:'text'|'json' = 'text') { |
||||
const observable = from(this.getDirectUploadFormFrom(url, file)) |
||||
.pipe( |
||||
switchMap(this.uploadToExternal(file, method, responseType)), |
||||
share() |
||||
); |
||||
|
||||
return [file, observable] as UploadInProgress; |
||||
} |
||||
|
||||
private uploadToExternal(file:UploadFile|UploadBlob, method:string, responseType:string):(result:PrepareUploadResult) => Observable<HttpEvent<unknown>> { |
||||
return result => { |
||||
result.form.append('file', file, file.customName || file.name); |
||||
|
||||
return this |
||||
.http |
||||
.request<HalResource>( |
||||
method, |
||||
result.url, |
||||
{ |
||||
body: result.form, |
||||
// Observe the response, not the body
|
||||
observe: 'events', |
||||
// This is important as the CORS policy for the bucket is * and you can't use credentals then,
|
||||
// besides we don't need them here anyway.
|
||||
withCredentials: false, |
||||
responseType: responseType as any, |
||||
// Subscribe to progress events. subscribe() will fire multiple times!
|
||||
reportProgress: true |
||||
} |
||||
) |
||||
.pipe(switchMap(this.finishUpload(result))); |
||||
}; |
||||
} |
||||
|
||||
private finishUpload(result:PrepareUploadResult):(result:HttpEvent<unknown>) => Observable<HttpEvent<unknown>> { |
||||
return event => { |
||||
if (event instanceof HttpResponse) { |
||||
return this |
||||
.http |
||||
.get( |
||||
result.response._links.completeUpload.href, |
||||
{ |
||||
observe: 'response' |
||||
} |
||||
); |
||||
} |
||||
|
||||
// Return as new observable due to switchMap
|
||||
return of(event); |
||||
}; |
||||
} |
||||
|
||||
public getDirectUploadFormFrom(url:string, file:UploadFile|UploadBlob):Promise<PrepareUploadResult> { |
||||
const formData = new FormData(); |
||||
const metadata = { |
||||
description: file.description, |
||||
fileName: file.customName || file.name, |
||||
fileSize: file.size, |
||||
contentType: file.type |
||||
}; |
||||
|
||||
/* |
||||
* @TODO We could calculate the MD5 hash here too and pass that. |
||||
* The MD5 hash can be used as the `content-md5` option during the upload to S3 for instance. |
||||
* This way S3 can verify the integrity of the file which we currently don't do. |
||||
*/ |
||||
|
||||
// add the metadata object
|
||||
formData.append( |
||||
'metadata', |
||||
JSON.stringify(metadata), |
||||
); |
||||
|
||||
const result = this |
||||
.http |
||||
.request<HalResource>( |
||||
"post", |
||||
url, |
||||
{ |
||||
body: formData, |
||||
withCredentials: true, |
||||
responseType: "json" as any |
||||
} |
||||
) |
||||
.toPromise() |
||||
.then((res) => { |
||||
let form = new FormData(); |
||||
|
||||
_.each(res._links.addAttachment.form_fields, (value, key) => { |
||||
form.append(key, value); |
||||
}); |
||||
|
||||
return { url: res._links.addAttachment.href, form: form, response: res }; |
||||
}); |
||||
|
||||
return result; |
||||
} |
||||
} |
@ -0,0 +1,62 @@ |
||||
import {Injectable} from "@angular/core"; |
||||
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service"; |
||||
import {input} from "reactivestates"; |
||||
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; |
||||
import {Observable} from "rxjs"; |
||||
import {filter, map, take} from "rxjs/operators"; |
||||
import {Board} from "core-app/modules/boards/board/board"; |
||||
|
||||
@Injectable() |
||||
export abstract class CachedBoardActionService extends BoardActionService { |
||||
protected cache = input<HalResource[]>(); |
||||
|
||||
protected loadValues(matching?:string):Observable<HalResource[]> { |
||||
this |
||||
.cache |
||||
.putFromPromiseIfPristine(() => this.loadUncached()); |
||||
|
||||
return this |
||||
.cache |
||||
.values$() |
||||
.pipe( |
||||
map(results => { |
||||
if (matching) { |
||||
return results.filter(resource => resource.name.includes(matching)); |
||||
} else { |
||||
return results; |
||||
} |
||||
}), |
||||
take(1) |
||||
); |
||||
} |
||||
|
||||
addColumnWithActionAttribute(board:Board, value:HalResource):Promise<Board> { |
||||
if (this.cache.value) { |
||||
// Add the new value to the cache
|
||||
let newValue = [...this.cache.value, value]; |
||||
this.cache.putValue(newValue); |
||||
} |
||||
|
||||
return super.addColumnWithActionAttribute(board, value); |
||||
} |
||||
|
||||
protected require(id:string):Promise<HalResource> { |
||||
this |
||||
.cache |
||||
.putFromPromiseIfPristine(() => this.loadUncached()); |
||||
|
||||
return this |
||||
.cache |
||||
.values$() |
||||
.pipe( |
||||
take(1) |
||||
) |
||||
.toPromise() |
||||
.then(results => { |
||||
return results.find(resource => resource.id === id)!; |
||||
}); |
||||
} |
||||
|
||||
protected abstract loadUncached():Promise<HalResource[]>; |
||||
} |
||||
|
@ -0,0 +1,62 @@ |
||||
import {Injectable} from "@angular/core"; |
||||
import {Board} from "core-app/modules/boards/board/board"; |
||||
import {StatusResource} from "core-app/modules/hal/resources/status-resource"; |
||||
import {BoardActionService} from "core-app/modules/boards/board/board-actions/board-action.service"; |
||||
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; |
||||
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; |
||||
import {Observable} from "rxjs"; |
||||
import {map} from "rxjs/operators"; |
||||
import {ApiV3FilterBuilder, buildApiV3Filter, FalseValue} from "core-components/api/api-v3/api-v3-filter-builder"; |
||||
import {SubtasksBoardHeaderComponent} from "core-app/modules/boards/board/board-actions/subtasks/subtasks-board-header.component"; |
||||
|
||||
@Injectable() |
||||
export class BoardSubtasksActionService extends BoardActionService { |
||||
filterName = 'parent'; |
||||
|
||||
text = this.I18n.t('js.boards.board_type.action_by_attribute', |
||||
{ attribute: this.I18n.t('js.boards.board_type.action_type.subtasks')}) ; |
||||
|
||||
description = this.I18n.t('js.boards.board_type.action_text_subtasks'); |
||||
|
||||
icon = 'icon-hierarchy'; |
||||
|
||||
public get localizedName() { |
||||
return this.I18n.t('js.boards.board_type.action_type.subtasks'); |
||||
} |
||||
|
||||
public headerComponent() { |
||||
return SubtasksBoardHeaderComponent; |
||||
} |
||||
|
||||
public canMove(workPackage:WorkPackageResource):boolean { |
||||
return !!workPackage.changeParent; |
||||
} |
||||
|
||||
protected loadValues(matching?:string):Observable<HalResource[]> { |
||||
let filters = new ApiV3FilterBuilder(); |
||||
filters.add('is_milestone', '=', false); |
||||
filters.add('project', '=', [this.currentProject.id]); |
||||
|
||||
if (matching) { |
||||
filters.add('subjectOrId', '**', [matching]); |
||||
} |
||||
|
||||
return this |
||||
.apiV3Service |
||||
.work_packages |
||||
.filtered(filters) |
||||
.get() |
||||
.pipe( |
||||
map(collection => collection.elements) |
||||
); |
||||
} |
||||
|
||||
protected require(id:string):Promise<HalResource> { |
||||
return this |
||||
.apiV3Service |
||||
.work_packages |
||||
.id(id) |
||||
.get() |
||||
.toPromise(); |
||||
} |
||||
} |
@ -0,0 +1,57 @@ |
||||
//-- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2020 the OpenProject GmbH
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See docs/COPYRIGHT.rdoc for more details.
|
||||
//++
|
||||
import {Component, Input, OnInit} from "@angular/core"; |
||||
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; |
||||
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; |
||||
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
||||
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; |
||||
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions"; |
||||
|
||||
|
||||
@Component({ |
||||
templateUrl: './subtasks-board-header.html', |
||||
styleUrls: ['./subtasks-board-header.sass'], |
||||
host: { 'class': 'title-container -small' } |
||||
}) |
||||
export class SubtasksBoardHeaderComponent implements OnInit { |
||||
@Input() public resource:WorkPackageResource; |
||||
|
||||
text = { |
||||
workPackage: this.I18n.t('js.label_work_package_parent') |
||||
}; |
||||
|
||||
typeHighlightingClass:string; |
||||
|
||||
constructor(readonly pathHelper:PathHelperService, |
||||
readonly I18n:I18nService) { |
||||
} |
||||
|
||||
ngOnInit() { |
||||
this.typeHighlightingClass = Highlighting.inlineClass('type', this.resource.type.id!); |
||||
} |
||||
} |
@ -0,0 +1,14 @@ |
||||
<div class="subtask-board-header" *ngIf="resource"> |
||||
<h2 class="editable-toolbar-title--fixed"> |
||||
<small [textContent]="text.workPackage"></small> |
||||
<br/> |
||||
<span class="work-package-type" |
||||
[ngClass]="typeHighlightingClass" |
||||
[textContent]="resource.type.name"> |
||||
</span> |
||||
<a [href]="pathHelper.workPackagePath(resource.idFromLink)" |
||||
[textContent]="resource.subjectWithId()" |
||||
target="_blank"> |
||||
</a> |
||||
</h2> |
||||
</div> |
@ -0,0 +1,7 @@ |
||||
// Override line-height for proper |
||||
// display of the h2 + small |
||||
.editable-toolbar-title--fixed |
||||
line-height: 1 !important |
||||
|
||||
.work-package-type |
||||
padding-right: 0.5rem |
@ -0,0 +1,158 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2020 the OpenProject GmbH |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'roar/decorator' |
||||
require 'roar/json/hal' |
||||
|
||||
module API |
||||
module V3 |
||||
module Attachments |
||||
class AttachmentUploadRepresenter < ::API::Decorators::Single |
||||
include API::Decorators::DateProperty |
||||
include API::Decorators::FormattableProperty |
||||
include API::Decorators::LinkedResource |
||||
|
||||
self_link title_getter: ->(*) { represented.filename } |
||||
|
||||
associated_resource :author, |
||||
v3_path: :user, |
||||
representer: ::API::V3::Users::UserRepresenter |
||||
|
||||
def self.associated_container_getter |
||||
->(*) do |
||||
next unless embed_links && container_representer |
||||
|
||||
container_representer |
||||
.new(represented.container, current_user: current_user) |
||||
end |
||||
end |
||||
|
||||
def self.associated_container_link |
||||
->(*) do |
||||
return nil unless v3_container_name == 'nil_class' || api_v3_paths.respond_to?(v3_container_name) |
||||
|
||||
::API::Decorators::LinkObject |
||||
.new(represented, |
||||
path: v3_container_name, |
||||
property_name: :container, |
||||
title_attribute: container_title_attribute) |
||||
.to_hash |
||||
end |
||||
end |
||||
|
||||
attr_reader :form_url |
||||
attr_reader :form_fields |
||||
|
||||
attr_reader :attachment |
||||
|
||||
def initialize(attachment, options = {}) |
||||
super |
||||
|
||||
fog_hash = DirectFogUploader.direct_fog_hash attachment: attachment |
||||
|
||||
@form_url = fog_hash[:uri] |
||||
@form_fields = fog_hash.except :uri |
||||
@attachment = attachment |
||||
end |
||||
|
||||
associated_resource :container, |
||||
getter: associated_container_getter, |
||||
link: associated_container_link |
||||
|
||||
link :addAttachment do |
||||
{ |
||||
href: form_url, |
||||
method: :post, |
||||
form_fields: form_fields |
||||
} |
||||
end |
||||
|
||||
link :delete do |
||||
{ |
||||
href: api_v3_paths.attachment_upload(represented.id), |
||||
method: :delete |
||||
} |
||||
end |
||||
|
||||
link :staticDownloadLocation do |
||||
{ |
||||
href: api_v3_paths.attachment_content(attachment.id) |
||||
} |
||||
end |
||||
|
||||
link :downloadLocation do |
||||
location = if attachment.external_storage? |
||||
attachment.external_url |
||||
else |
||||
api_v3_paths.attachment_content(attachment.id) |
||||
end |
||||
{ |
||||
href: location |
||||
} |
||||
end |
||||
|
||||
link :completeUpload do |
||||
{ |
||||
href: api_v3_paths.attachment_uploaded(attachment.id) |
||||
} |
||||
end |
||||
|
||||
property :id |
||||
property :file_name, |
||||
getter: ->(*) { filename } |
||||
|
||||
formattable_property :description, |
||||
plain: true |
||||
|
||||
date_time_property :created_at |
||||
|
||||
def _type |
||||
'AttachmentUpload' |
||||
end |
||||
|
||||
def container_representer |
||||
name = v3_container_name.camelcase |
||||
|
||||
"::API::V3::#{name.pluralize}::#{name}Representer".constantize |
||||
rescue NameError |
||||
nil |
||||
end |
||||
|
||||
def v3_container_name |
||||
::API::Utilities::PropertyNameConverter.from_ar_name(represented.container.class.name.underscore).underscore |
||||
end |
||||
|
||||
def container_title_attribute |
||||
represented.container.respond_to?(:subject) ? :subject : :title |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue