Organize directories
This commit is contained in:
parent
8a91c9b89b
commit
4898382f78
433 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,256 @@
|
|||
class Index extends App.ControllerIntegrationBase
|
||||
featureIntegration: 'pgp_integration'
|
||||
featureName: 'PGP'
|
||||
featureConfig: 'pgp_config'
|
||||
description: [
|
||||
['PGP (Pretty Good Privacy) is a widely accepted method (or more precisely, a protocol) for sending digitally signed and encrypted messages.']
|
||||
]
|
||||
events:
|
||||
'change .js-switch input': 'switch'
|
||||
|
||||
render: =>
|
||||
super
|
||||
new Form(
|
||||
el: @$('.js-form')
|
||||
)
|
||||
|
||||
new App.HttpLog(
|
||||
el: @$('.js-log')
|
||||
facility: 'PGP'
|
||||
)
|
||||
|
||||
class Form extends App.Controller
|
||||
events:
|
||||
'click .js-addPublicKey': 'addPublicKey'
|
||||
'click .js-addPrivateKey': 'addPrivateKey'
|
||||
'click .js-updateGroup': 'updateGroup'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@render()
|
||||
|
||||
currentConfig: ->
|
||||
App.Setting.get('pgp_config')
|
||||
|
||||
setConfig: (value) ->
|
||||
App.Setting.set('pgp_config', value, {notify: true})
|
||||
|
||||
render: =>
|
||||
@config = @currentConfig()
|
||||
|
||||
@html App.view('integration/pgp')(
|
||||
config: @config
|
||||
)
|
||||
@keyList()
|
||||
@groupList()
|
||||
|
||||
keyList: =>
|
||||
new List(el: @$('.js-keyList'))
|
||||
|
||||
groupList: =>
|
||||
new Group(
|
||||
el: @$('.js-groupList')
|
||||
config: @config
|
||||
)
|
||||
|
||||
addPublicKey: =>
|
||||
new PublicKey(
|
||||
callback: @keyList
|
||||
)
|
||||
|
||||
addPrivateKey: =>
|
||||
new PrivateKey(
|
||||
callback: @keyList
|
||||
)
|
||||
|
||||
updateGroup: (e) =>
|
||||
params = App.ControllerForm.params(e)
|
||||
@setConfig(params)
|
||||
|
||||
class PublicKey extends App.ControllerModal
|
||||
buttonClose: true
|
||||
buttonCancel: true
|
||||
buttonSubmit: 'Add'
|
||||
autoFocusOnFirstInput: false
|
||||
head: 'Add Public Key'
|
||||
large: true
|
||||
|
||||
content: ->
|
||||
|
||||
# show start dialog
|
||||
content = $(App.view('integration/pgp_public_key_add')(
|
||||
head: 'Add Public Key'
|
||||
))
|
||||
content
|
||||
|
||||
onSubmit: (e) =>
|
||||
params = new FormData($(e.currentTarget).closest('form').get(0))
|
||||
params.set('try', true)
|
||||
if _.isEmpty(params.get('data'))
|
||||
params.delete('data')
|
||||
@formDisable(e)
|
||||
|
||||
@ajax(
|
||||
id: 'pgp-public_key-add'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/integration/pgp/public_key"
|
||||
processData: false
|
||||
contentType: false
|
||||
cache: false
|
||||
data: params
|
||||
success: (data, status, xhr) =>
|
||||
@close()
|
||||
@callback()
|
||||
error: (data) =>
|
||||
@close()
|
||||
details = data.responseJSON || {}
|
||||
@notify
|
||||
type: 'error'
|
||||
msg: App.i18n.translateContent(details.error_human || details.error || 'The import failed.')
|
||||
timeout: 6000
|
||||
)
|
||||
|
||||
class PrivateKey extends App.ControllerModal
|
||||
buttonClose: true
|
||||
buttonCancel: true
|
||||
buttonSubmit: 'Add'
|
||||
autoFocusOnFirstInput: false
|
||||
head: 'Add Private Key'
|
||||
large: true
|
||||
|
||||
content: ->
|
||||
|
||||
# show start dialog
|
||||
content = $(App.view('integration/pgp_private_key_add')(
|
||||
head: 'Add Private Key'
|
||||
))
|
||||
content
|
||||
|
||||
onSubmit: (e) =>
|
||||
params = new FormData($(e.currentTarget).closest('form').get(0))
|
||||
params.set('try', true)
|
||||
if _.isEmpty(params.get('data'))
|
||||
params.delete('data')
|
||||
@formDisable(e)
|
||||
|
||||
@ajax(
|
||||
id: 'pgp-private_key-add'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/integration/pgp/private_key"
|
||||
processData: false
|
||||
contentType: false
|
||||
cache: false
|
||||
data: params
|
||||
success: (data, status, xhr) =>
|
||||
@close()
|
||||
@callback()
|
||||
error: (data) =>
|
||||
@close()
|
||||
details = data.responseJSON || {}
|
||||
@notify
|
||||
type: 'error'
|
||||
msg: App.i18n.translateContent(details.error_human || details.error || 'The import failed.')
|
||||
timeout: 6000
|
||||
)
|
||||
|
||||
|
||||
class List extends App.Controller
|
||||
events:
|
||||
'click .js-remove': 'remove'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@load()
|
||||
|
||||
load: =>
|
||||
@ajax(
|
||||
id: 'pgp-list'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/integration/pgp/public_key"
|
||||
success: (data, status, xhr) =>
|
||||
@render(data)
|
||||
|
||||
error: (data, status) =>
|
||||
|
||||
# do not close window if request is aborted
|
||||
return if status is 'abort'
|
||||
|
||||
details = data.responseJSON || {}
|
||||
@notify(
|
||||
type: 'error'
|
||||
msg: App.i18n.translateContent(details.error_human || details.error || 'Loading failed.')
|
||||
)
|
||||
|
||||
# do something
|
||||
)
|
||||
|
||||
render: (data) =>
|
||||
@html App.view('integration/pgp_list')(
|
||||
keyPairs: data
|
||||
)
|
||||
|
||||
remove: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.currentTarget).parents('tr').data('id')
|
||||
return if !id
|
||||
|
||||
@ajax(
|
||||
id: 'pgp-list'
|
||||
type: 'DELETE'
|
||||
url: "#{@apiPath}/integration/pgp/public_key"
|
||||
data: JSON.stringify(id: id)
|
||||
success: (data, status, xhr) =>
|
||||
@load()
|
||||
|
||||
error: (data, status) =>
|
||||
|
||||
# do not close window if request is aborted
|
||||
return if status is 'abort'
|
||||
|
||||
details = data.responseJSON || {}
|
||||
@notify(
|
||||
type: 'error'
|
||||
msg: App.i18n.translateContent(details.error_human || details.error || 'Server operation failed.')
|
||||
)
|
||||
)
|
||||
|
||||
class Group extends App.Controller
|
||||
constructor: ->
|
||||
super
|
||||
@render()
|
||||
|
||||
render: (data) =>
|
||||
groups = App.Group.search(sortBy: 'name', filter: active: true)
|
||||
@html App.view('integration/pgp_group')(
|
||||
groups: groups
|
||||
)
|
||||
for group in groups
|
||||
for type, selector of { default_sign: 'js-signDefault', default_encryption: 'js-encryptionDefault' }
|
||||
selected = true
|
||||
if @config?.group_id && @config.group_id[type]
|
||||
selected = @config.group_id[type][group.id.toString()]
|
||||
selection = App.UiElement.boolean.render(
|
||||
name: "group_id::#{type}::#{group.id}"
|
||||
multiple: false
|
||||
null: false
|
||||
nulloption: false
|
||||
value: selected
|
||||
class: 'form-control--small'
|
||||
)
|
||||
@$("[data-id=#{group.id}] .#{selector}").html(selection)
|
||||
|
||||
class State
|
||||
@current: ->
|
||||
App.Setting.get('pgp_integration')
|
||||
|
||||
App.Config.set(
|
||||
'Integrationpgp'
|
||||
{
|
||||
name: 'PGP'
|
||||
target: '#system/integration/pgp'
|
||||
description: 'PGP enables you to send digitally signed and encrypted messages.'
|
||||
controller: Index
|
||||
state: State
|
||||
}
|
||||
'NavBarIntegrations'
|
||||
)
|
||||
|
|
@ -0,0 +1,614 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
class App.UiElement.ticket_perform_action
|
||||
@defaults: (attribute) ->
|
||||
defaults = ['ticket.state_id']
|
||||
|
||||
groups =
|
||||
ticket:
|
||||
name: 'Ticket'
|
||||
model: 'Ticket'
|
||||
article:
|
||||
name: 'Article'
|
||||
model: 'Article'
|
||||
|
||||
if attribute.notification
|
||||
groups.notification =
|
||||
name: 'Notification'
|
||||
model: 'Notification'
|
||||
|
||||
# merge config
|
||||
elements = {}
|
||||
for groupKey, groupMeta of groups
|
||||
if !groupMeta.model || !App[groupMeta.model]
|
||||
if groupKey is 'notification'
|
||||
elements["#{groupKey}.email"] = { name: 'email', display: 'Email' }
|
||||
elements["#{groupKey}.sms"] = { name: 'sms', display: 'SMS' }
|
||||
elements["#{groupKey}.webhook"] = { name: 'webhook', display: 'Webhook' }
|
||||
else if groupKey is 'article'
|
||||
elements["#{groupKey}.note"] = { name: 'note', display: 'Note' }
|
||||
else
|
||||
|
||||
for row in App[groupMeta.model].configure_attributes
|
||||
|
||||
# ignore passwords and relations
|
||||
if row.type isnt 'password' && row.name.substr(row.name.length-4,4) isnt '_ids'
|
||||
|
||||
# ignore readonly attributes
|
||||
if !row.readonly
|
||||
config = _.clone(row)
|
||||
|
||||
switch config.tag
|
||||
when 'datetime'
|
||||
config.operator = ['static', 'relative']
|
||||
when 'tag'
|
||||
config.operator = ['add', 'remove']
|
||||
|
||||
elements["#{groupKey}.#{config.name}"] = config
|
||||
|
||||
# add ticket deletion action
|
||||
if attribute.ticket_delete
|
||||
elements['ticket.action'] =
|
||||
name: 'action'
|
||||
display: 'Action'
|
||||
tag: 'select'
|
||||
null: false
|
||||
translate: true
|
||||
options:
|
||||
delete: 'Delete'
|
||||
|
||||
[defaults, groups, elements]
|
||||
|
||||
@placeholder: (elementFull, attribute, params, groups, elements) ->
|
||||
item = $( App.view('generic/ticket_perform_action/row')( attribute: attribute ) )
|
||||
selector = @buildAttributeSelector(elementFull, groups, elements)
|
||||
item.find('.js-attributeSelector').prepend(selector)
|
||||
item
|
||||
|
||||
@render: (attribute, params = {}) ->
|
||||
|
||||
[defaults, groups, elements] = @defaults(attribute)
|
||||
|
||||
# return item
|
||||
item = $( App.view('generic/ticket_perform_action/index')( attribute: attribute ) )
|
||||
|
||||
# add filter
|
||||
item.on('click', '.js-rowActions .js-add', (e) =>
|
||||
element = $(e.target).closest('.js-filterElement')
|
||||
placeholder = @placeholder(item, attribute, params, groups, elements)
|
||||
if element.get(0)
|
||||
element.after(placeholder)
|
||||
else
|
||||
item.append(placeholder)
|
||||
placeholder.find('.js-attributeSelector select').trigger('change')
|
||||
@updateAttributeSelectors(item)
|
||||
)
|
||||
|
||||
# remove filter
|
||||
item.on('click', '.js-rowActions .js-remove', (e) =>
|
||||
return if $(e.currentTarget).hasClass('is-disabled')
|
||||
$(e.target).closest('.js-filterElement').remove()
|
||||
@updateAttributeSelectors(item)
|
||||
)
|
||||
|
||||
# change attribute selector
|
||||
item.on('change', '.js-attributeSelector select', (e) =>
|
||||
elementRow = $(e.target).closest('.js-filterElement')
|
||||
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
|
||||
@rebuildAttributeSelectors(item, elementRow, groupAndAttribute, elements, {}, attribute)
|
||||
@updateAttributeSelectors(item)
|
||||
)
|
||||
|
||||
# change operator selector
|
||||
item.on('change', '.js-operator select', (e) =>
|
||||
elementRow = $(e.target).closest('.js-filterElement')
|
||||
groupAndAttribute = elementRow.find('.js-attributeSelector option:selected').attr('value')
|
||||
@buildOperator(item, elementRow, groupAndAttribute, elements, {}, attribute)
|
||||
)
|
||||
|
||||
# build initial params
|
||||
if _.isEmpty(params[attribute.name])
|
||||
|
||||
for groupAndAttribute in defaults
|
||||
|
||||
# build and append
|
||||
element = @placeholder(item, attribute, params, groups, elements)
|
||||
item.append(element)
|
||||
@rebuildAttributeSelectors(item, element, groupAndAttribute, elements, {}, attribute)
|
||||
|
||||
else
|
||||
|
||||
for groupAndAttribute, meta of params[attribute.name]
|
||||
|
||||
# build and append
|
||||
element = @placeholder(item, attribute, params, groups, elements)
|
||||
@rebuildAttributeSelectors(item, element, groupAndAttribute, elements, meta, attribute)
|
||||
item.append(element)
|
||||
|
||||
@disableRemoveForOneAttribute(item)
|
||||
item
|
||||
|
||||
@buildAttributeSelector: (elementFull, groups, elements) ->
|
||||
|
||||
# find first possible attribute
|
||||
selectedValue = ''
|
||||
elementFull.find('.js-attributeSelector select option').each(->
|
||||
if !selectedValue && !$(@).prop('disabled')
|
||||
selectedValue = $(@).val()
|
||||
)
|
||||
|
||||
selection = $('<select class="form-control"></select>')
|
||||
for groupKey, groupMeta of groups
|
||||
displayName = App.i18n.translateInline(groupMeta.name)
|
||||
selection.closest('select').append("<optgroup label=\"#{displayName}\" class=\"js-#{groupKey}\"></optgroup>")
|
||||
optgroup = selection.find("optgroup.js-#{groupKey}")
|
||||
for elementKey, elementGroup of elements
|
||||
spacer = elementKey.split(/\./)
|
||||
if spacer[0] is groupKey
|
||||
attributeConfig = elements[elementKey]
|
||||
displayName = App.i18n.translateInline(attributeConfig.display)
|
||||
|
||||
selected = ''
|
||||
if elementKey is selectedValue
|
||||
selected = 'selected="selected"'
|
||||
optgroup.append("<option value=\"#{elementKey}\" #{selected}>#{displayName}</option>")
|
||||
selection
|
||||
|
||||
# disable - if we only have one attribute
|
||||
@disableRemoveForOneAttribute: (elementFull) ->
|
||||
if elementFull.find('.js-attributeSelector select').length > 1
|
||||
elementFull.find('.js-remove').removeClass('is-disabled')
|
||||
else
|
||||
elementFull.find('.js-remove').addClass('is-disabled')
|
||||
|
||||
@updateAttributeSelectors: (elementFull) ->
|
||||
|
||||
# enable all
|
||||
elementFull.find('.js-attributeSelector select option').prop('disabled', false)
|
||||
|
||||
# disable all used attributes
|
||||
elementFull.find('.js-attributeSelector select').each(->
|
||||
keyLocal = $(@).val()
|
||||
elementFull.find('.js-attributeSelector select option[value="' + keyLocal + '"]').attr('disabled', true)
|
||||
)
|
||||
|
||||
# disable - if we only have one attribute
|
||||
@disableRemoveForOneAttribute(elementFull)
|
||||
|
||||
@rebuildAttributeSelectors: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
|
||||
# set attribute
|
||||
if groupAndAttribute
|
||||
elementRow.find('.js-attributeSelector select').val(groupAndAttribute)
|
||||
|
||||
notificationTypeMatch = groupAndAttribute.match(/^notification.([\w]+)$/)
|
||||
articleTypeMatch = groupAndAttribute.match(/^article.([\w]+)$/)
|
||||
|
||||
if _.isArray(notificationTypeMatch) && notificationType = notificationTypeMatch[1]
|
||||
elementRow.find('.js-setAttribute').html('').addClass('hide')
|
||||
elementRow.find('.js-setArticle').html('').addClass('hide')
|
||||
@buildNotificationArea(notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
else if _.isArray(articleTypeMatch) && articleType = articleTypeMatch[1]
|
||||
elementRow.find('.js-setAttribute').html('').addClass('hide')
|
||||
elementRow.find('.js-setNotification').html('').addClass('hide')
|
||||
@buildArticleArea(articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
else
|
||||
elementRow.find('.js-setNotification').html('').addClass('hide')
|
||||
elementRow.find('.js-setArticle').html('').addClass('hide')
|
||||
if !elementRow.find('.js-setAttribute div').get(0)
|
||||
attributeSelectorElement = $( App.view('generic/ticket_perform_action/attribute_selector')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
meta: meta || {}
|
||||
))
|
||||
elementRow.find('.js-setAttribute').html(attributeSelectorElement).removeClass('hide')
|
||||
@buildOperator(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
|
||||
@buildOperator: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
|
||||
|
||||
if !meta.operator
|
||||
meta.operator = currentOperator
|
||||
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::operator"
|
||||
|
||||
selection = $("<select class=\"form-control\" name=\"#{name}\"></select>")
|
||||
attributeConfig = elements[groupAndAttribute]
|
||||
if !attributeConfig || !attributeConfig.operator
|
||||
elementRow.find('.js-operator').parent().addClass('hide')
|
||||
else
|
||||
elementRow.find('.js-operator').parent().removeClass('hide')
|
||||
if attributeConfig && attributeConfig.operator
|
||||
for operator in attributeConfig.operator
|
||||
operatorName = App.i18n.translateInline(operator)
|
||||
selected = ''
|
||||
if meta.operator is operator
|
||||
selected = 'selected="selected"'
|
||||
selection.append("<option value=\"#{operator}\" #{selected}>#{operatorName}</option>")
|
||||
selection
|
||||
|
||||
elementRow.find('.js-operator select').replaceWith(selection)
|
||||
|
||||
@buildPreCondition(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
|
||||
@buildPreCondition: (elementFull, elementRow, groupAndAttribute, elements, meta, attributeConfig) ->
|
||||
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
|
||||
currentPreCondition = elementRow.find('.js-preCondition option:selected').attr('value')
|
||||
|
||||
if !meta.pre_condition
|
||||
meta.pre_condition = currentPreCondition
|
||||
|
||||
toggleValue = =>
|
||||
preCondition = elementRow.find('.js-preCondition option:selected').attr('value')
|
||||
if preCondition isnt 'specific'
|
||||
elementRow.find('.js-value select').html('')
|
||||
elementRow.find('.js-value').addClass('hide')
|
||||
else
|
||||
elementRow.find('.js-value').removeClass('hide')
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
|
||||
# force to use auto complition on user lookup
|
||||
attribute = _.clone(attributeConfig)
|
||||
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::value"
|
||||
attributeSelected = elements[groupAndAttribute]
|
||||
|
||||
preCondition = false
|
||||
if attributeSelected.relation is 'User'
|
||||
preCondition = 'user'
|
||||
attribute.tag = 'user_autocompletion'
|
||||
if attributeSelected.relation is 'Organization'
|
||||
preCondition = 'org'
|
||||
attribute.tag = 'autocompletion_ajax'
|
||||
if !preCondition
|
||||
elementRow.find('.js-preCondition select').html('')
|
||||
elementRow.find('.js-preCondition').closest('.controls').addClass('hide')
|
||||
toggleValue()
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
return
|
||||
|
||||
elementRow.find('.js-preCondition').closest('.controls').removeClass('hide')
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::pre_condition"
|
||||
|
||||
selection = $("<select class=\"form-control\" name=\"#{name}\" ></select>")
|
||||
options = {}
|
||||
if preCondition is 'user'
|
||||
options =
|
||||
'current_user.id': App.i18n.translateInline('current user')
|
||||
'specific': App.i18n.translateInline('specific user')
|
||||
|
||||
if attributeSelected.null is true
|
||||
options['not_set'] = App.i18n.translateInline('unassign user')
|
||||
|
||||
else if preCondition is 'org'
|
||||
options =
|
||||
'current_user.organization_id': App.i18n.translateInline('current user organization')
|
||||
'specific': App.i18n.translateInline('specific organization')
|
||||
|
||||
for key, value of options
|
||||
selected = ''
|
||||
if key is meta.pre_condition
|
||||
selected = 'selected="selected"'
|
||||
selection.append("<option value=\"#{key}\" #{selected}>#{App.i18n.translateInline(value)}</option>")
|
||||
elementRow.find('.js-preCondition').closest('.controls').removeClass('hide')
|
||||
elementRow.find('.js-preCondition select').replaceWith(selection)
|
||||
|
||||
elementRow.find('.js-preCondition select').on('change', (e) ->
|
||||
toggleValue()
|
||||
)
|
||||
|
||||
@buildValue(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
|
||||
toggleValue()
|
||||
|
||||
@buildValue: (elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
name = "#{attribute.name}::#{groupAndAttribute}::value"
|
||||
|
||||
# build new item
|
||||
attributeConfig = elements[groupAndAttribute]
|
||||
config = _.clone(attributeConfig)
|
||||
|
||||
if config.relation is 'User'
|
||||
config.tag = 'user_autocompletion'
|
||||
if config.relation is 'Organization'
|
||||
config.tag = 'autocompletion_ajax'
|
||||
|
||||
# render ui element
|
||||
item = ''
|
||||
if config && App.UiElement[config.tag]
|
||||
config['name'] = name
|
||||
if attribute.value && attribute.value[groupAndAttribute]
|
||||
config['value'] = _.clone(attribute.value[groupAndAttribute]['value'])
|
||||
config.multiple = false
|
||||
config.nulloption = config.null
|
||||
if config.tag is 'checkbox'
|
||||
config.tag = 'select'
|
||||
tagSearch = "#{config.tag}_search"
|
||||
if config.tag is 'datetime'
|
||||
config.validationContainer = 'self'
|
||||
if App.UiElement[tagSearch]
|
||||
item = App.UiElement[tagSearch].render(config, {})
|
||||
else
|
||||
item = App.UiElement[config.tag].render(config, {})
|
||||
|
||||
relative_operators = [
|
||||
'before (relative)',
|
||||
'within next (relative)',
|
||||
'within last (relative)',
|
||||
'after (relative)',
|
||||
'till (relative)',
|
||||
'from (relative)',
|
||||
'relative'
|
||||
]
|
||||
|
||||
upcoming_operator = meta.operator
|
||||
|
||||
if !_.include(config.operator, upcoming_operator)
|
||||
if Array.isArray(config.operator)
|
||||
upcoming_operator = config.operator[0]
|
||||
else
|
||||
upcoming_operator = null
|
||||
|
||||
if _.include(relative_operators, upcoming_operator)
|
||||
config['name'] = "#{attribute.name}::#{groupAndAttribute}"
|
||||
if attribute.value && attribute.value[groupAndAttribute]
|
||||
config['value'] = _.clone(attribute.value[groupAndAttribute])
|
||||
item = App.UiElement['time_range'].render(config, {})
|
||||
|
||||
elementRow.find('.js-setAttribute > .flex > .js-value').removeClass('hide').html(item)
|
||||
|
||||
@buildNotificationArea: (notificationType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
|
||||
return if elementRow.find(".js-setNotification .js-body-#{notificationType}").get(0)
|
||||
|
||||
elementRow.find('.js-setNotification').empty()
|
||||
|
||||
options =
|
||||
'article_last_sender': 'Sender of last article'
|
||||
'ticket_owner': 'Owner'
|
||||
'ticket_customer': 'Customer'
|
||||
'ticket_agents': 'All agents'
|
||||
|
||||
name = "#{attribute.name}::notification.#{notificationType}"
|
||||
|
||||
messageLength = switch notificationType
|
||||
when 'sms' then 160
|
||||
else 200000
|
||||
|
||||
# meta.recipient was a string in the past (single-select) so we convert it to array if needed
|
||||
if !_.isArray(meta.recipient)
|
||||
meta.recipient = [meta.recipient]
|
||||
|
||||
columnSelectOptions = []
|
||||
for key, value of options
|
||||
selected = undefined
|
||||
for recipient in meta.recipient
|
||||
if key is recipient
|
||||
selected = true
|
||||
columnSelectOptions.push({ value: key, name: App.i18n.translatePlain(value), selected: selected })
|
||||
|
||||
columnSelectRecipientUserOptions = []
|
||||
for user in App.User.all()
|
||||
key = "userid_#{user.id}"
|
||||
selected = undefined
|
||||
for recipient in meta.recipient
|
||||
if key is recipient
|
||||
selected = true
|
||||
columnSelectRecipientUserOptions.push({ value: key, name: "#{user.firstname} #{user.lastname}", selected: selected })
|
||||
|
||||
columnSelectRecipient = new App.ColumnSelect
|
||||
attribute:
|
||||
name: "#{name}::recipient"
|
||||
options: [
|
||||
{
|
||||
label: 'Variables',
|
||||
group: columnSelectOptions
|
||||
},
|
||||
{
|
||||
label: 'User',
|
||||
group: columnSelectRecipientUserOptions
|
||||
},
|
||||
]
|
||||
|
||||
selectionRecipient = columnSelectRecipient.element()
|
||||
|
||||
if notificationType is 'webhook'
|
||||
notificationElement = $( App.view('generic/ticket_perform_action/webhook')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
notificationType: notificationType
|
||||
meta: meta || {}
|
||||
))
|
||||
|
||||
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
|
||||
|
||||
|
||||
if App.Webhook.search(filter: { active: true }).length isnt 0 || !_.isEmpty(meta.webhook_id)
|
||||
webhookSelection = App.UiElement.select.render(
|
||||
name: "#{name}::webhook_id"
|
||||
multiple: false
|
||||
null: false
|
||||
relation: 'Webhook'
|
||||
value: meta.webhook_id
|
||||
translate: false
|
||||
nulloption: true
|
||||
)
|
||||
else
|
||||
webhookSelection = App.view('generic/ticket_perform_action/webhook_not_available')( attribute: attribute )
|
||||
|
||||
notificationElement.find('.js-webhooks').html(webhookSelection)
|
||||
|
||||
else
|
||||
notificationElement = $( App.view('generic/ticket_perform_action/notification')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
notificationType: notificationType
|
||||
meta: meta || {}
|
||||
))
|
||||
|
||||
notificationElement.find('.js-recipient select').replaceWith(selectionRecipient)
|
||||
|
||||
visibilitySelection = App.UiElement.select.render(
|
||||
name: "#{name}::internal"
|
||||
multiple: false
|
||||
null: false
|
||||
options: { true: 'internal', false: 'public' }
|
||||
value: meta.internal || 'false'
|
||||
translate: true
|
||||
)
|
||||
|
||||
includeAttachmentsCheckbox = App.UiElement.select.render(
|
||||
name: "#{name}::include_attachments"
|
||||
multiple: false
|
||||
null: false
|
||||
options: { true: 'Yes', false: 'No' }
|
||||
value: meta.include_attachments || 'false'
|
||||
translate: true
|
||||
)
|
||||
|
||||
notificationElement.find('.js-internal').html(visibilitySelection)
|
||||
notificationElement.find('.js-include_attachments').html(includeAttachmentsCheckbox)
|
||||
|
||||
notificationElement.find('.js-body div[contenteditable="true"]').ce(
|
||||
mode: 'richtext'
|
||||
placeholder: 'message'
|
||||
maxlength: messageLength
|
||||
)
|
||||
new App.WidgetPlaceholder(
|
||||
el: notificationElement.find('.js-body div[contenteditable="true"]').parent()
|
||||
objects: [
|
||||
{
|
||||
prefix: 'ticket'
|
||||
object: 'Ticket'
|
||||
display: 'Ticket'
|
||||
},
|
||||
{
|
||||
prefix: 'article'
|
||||
object: 'TicketArticle'
|
||||
display: 'Article'
|
||||
},
|
||||
{
|
||||
prefix: 'user'
|
||||
object: 'User'
|
||||
display: 'Current User'
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
elementRow.find('.js-setNotification').html(notificationElement).removeClass('hide')
|
||||
|
||||
if App.Config.get('smime_integration') == true || App.Config.get('pgp_integration') == true
|
||||
selection = App.UiElement.select.render(
|
||||
name: "#{name}::sign"
|
||||
multiple: false
|
||||
options: {
|
||||
'no': 'Do not sign email'
|
||||
'discard': 'Sign email (if not possible, discard notification)'
|
||||
'always': 'Sign email (if not possible, send notification anyway)'
|
||||
}
|
||||
value: meta.sign
|
||||
translate: true
|
||||
)
|
||||
|
||||
elementRow.find('.js-sign').html(selection)
|
||||
|
||||
selection = App.UiElement.select.render(
|
||||
name: "#{name}::encryption"
|
||||
multiple: false
|
||||
options: {
|
||||
'no': 'Do not encrypt email'
|
||||
'discard': 'Encrypt email (if not possible, discard notification)'
|
||||
'always': 'Encrypt email (if not possible, send notification anyway)'
|
||||
}
|
||||
value: meta.encryption
|
||||
translate: true
|
||||
)
|
||||
|
||||
elementRow.find('.js-encryption').html(selection)
|
||||
|
||||
@buildArticleArea: (articleType, elementFull, elementRow, groupAndAttribute, elements, meta, attribute) ->
|
||||
|
||||
return if elementRow.find(".js-setArticle .js-body-#{articleType}").get(0)
|
||||
|
||||
elementRow.find('.js-setArticle').empty()
|
||||
|
||||
name = "#{attribute.name}::article.#{articleType}"
|
||||
selection = App.UiElement.select.render(
|
||||
name: "#{name}::internal"
|
||||
multiple: false
|
||||
null: false
|
||||
label: 'Visibility'
|
||||
options: { true: 'internal', false: 'public' }
|
||||
value: meta.internal
|
||||
translate: true
|
||||
)
|
||||
articleElement = $( App.view('generic/ticket_perform_action/article')(
|
||||
attribute: attribute
|
||||
name: name
|
||||
articleType: articleType
|
||||
meta: meta || {}
|
||||
))
|
||||
articleElement.find('.js-internal').html(selection)
|
||||
articleElement.find('.js-body div[contenteditable="true"]').ce(
|
||||
mode: 'richtext'
|
||||
placeholder: 'message'
|
||||
maxlength: 200000
|
||||
)
|
||||
new App.WidgetPlaceholder(
|
||||
el: articleElement.find('.js-body div[contenteditable="true"]').parent()
|
||||
objects: [
|
||||
{
|
||||
prefix: 'ticket'
|
||||
object: 'Ticket'
|
||||
display: 'Ticket'
|
||||
},
|
||||
{
|
||||
prefix: 'article'
|
||||
object: 'TicketArticle'
|
||||
display: 'Article'
|
||||
},
|
||||
{
|
||||
prefix: 'user'
|
||||
object: 'User'
|
||||
display: 'Current User'
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
elementRow.find('.js-setArticle').html(articleElement).removeClass('hide')
|
||||
|
||||
@humanText: (condition) ->
|
||||
none = App.i18n.translateContent('No filter.')
|
||||
return [none] if _.isEmpty(condition)
|
||||
[defaults, groups, operators, elements] = @defaults()
|
||||
rules = []
|
||||
for attribute, value of condition
|
||||
|
||||
objectAttribute = attribute.split(/\./)
|
||||
|
||||
# get stored params
|
||||
if meta && objectAttribute[1]
|
||||
model = toCamelCase(objectAttribute[0])
|
||||
config = elements[attribute]
|
||||
|
||||
valueHuman = []
|
||||
if _.isArray(value)
|
||||
for data in value
|
||||
r = @humanTextLookup(config, data)
|
||||
valueHuman.push r
|
||||
else
|
||||
valueHuman.push @humanTextLookup(config, value)
|
||||
|
||||
if valueHuman.join
|
||||
valueHuman = valueHuman.join(', ')
|
||||
rules.push "#{App.i18n.translateContent('Set')} <b>#{App.i18n.translateContent(model)} -> #{App.i18n.translateContent(config.display)}</b> #{App.i18n.translateContent('to')} <b>#{valueHuman}</b>."
|
||||
|
||||
return [none] if _.isEmpty(rules)
|
||||
rules
|
||||
|
||||
@humanTextLookup: (config, value) ->
|
||||
return value if !App[config.relation]
|
||||
return value if !App[config.relation].exists(value)
|
||||
data = App[config.relation].fullLocal(value)
|
||||
return value if !data
|
||||
if data.displayName
|
||||
return App.i18n.translateContent( data.displayName() )
|
||||
valueHuman.push App.i18n.translateContent( data.name )
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# Methods for displaying security ui elements and to get security params
|
||||
|
||||
App.SecurityOptions =
|
||||
|
||||
securityOptionsShow: ->
|
||||
@$('.js-securityOptions').removeClass('hide')
|
||||
|
||||
securityOptionsHide: ->
|
||||
@$('.js-securityOptions').addClass('hide')
|
||||
|
||||
securityOptionsShown: ->
|
||||
!@$('.js-securityOptions').hasClass('hide')
|
||||
|
||||
securityEnabled: ->
|
||||
App.Config.get('smime_integration') || App.Config.get('pgp_integration')
|
||||
|
||||
paramsSecurity: =>
|
||||
if @$('.js-securityOptions').hasClass('hide')
|
||||
return {}
|
||||
|
||||
security = {}
|
||||
security.encryption ||= {}
|
||||
security.sign ||= {}
|
||||
if App.Config.get('pgp_integration')
|
||||
security.type = 'PGP'
|
||||
else
|
||||
security.type = 'S/MIME'
|
||||
if @$('.js-securityEncrypt').hasClass('btn--active')
|
||||
security.encryption.success = true
|
||||
if @$('.js-securitySign').hasClass('btn--active')
|
||||
security.sign.success = true
|
||||
security
|
||||
|
||||
updateSecurityOptionsRemote: (key, ticket, article, securityOptions) ->
|
||||
if securityOptions.type == 'PGP'
|
||||
id = "pgp-check-#{key}"
|
||||
url = "#{@apiPath}/integration/pgp"
|
||||
securityConfig = App.Config.get('pgp_config')
|
||||
else
|
||||
id = "smime-check-#{key}"
|
||||
url = "#{@apiPath}/integration/smime"
|
||||
securityConfig = App.Config.get('smime_config')
|
||||
callback = =>
|
||||
@ajax(
|
||||
id: id
|
||||
type: 'POST'
|
||||
url: url
|
||||
data: JSON.stringify(ticket: ticket, article: article)
|
||||
processData: true
|
||||
success: (data, status, xhr) =>
|
||||
|
||||
# get default selected security options
|
||||
selected =
|
||||
encryption: true
|
||||
sign: true
|
||||
for type, selector of { default_sign: 'sign', default_encryption: 'encryption' }
|
||||
if securityConfig?.group_id?[type] && ticket.group_id
|
||||
if securityConfig.group_id[type][ticket.group_id.toString()] == false
|
||||
selected[selector] = false
|
||||
|
||||
@$('.js-securityEncryptComment').attr('title', data.encryption.comment)
|
||||
|
||||
# if encryption is possible
|
||||
if data.encryption.success is true
|
||||
@$('.js-securityEncrypt').attr('disabled', false)
|
||||
|
||||
# overrule current selection with Group configuration
|
||||
if selected.encryption
|
||||
@$('.js-securityEncrypt').addClass('btn--active')
|
||||
else
|
||||
@$('.js-securityEncrypt').removeClass('btn--active')
|
||||
|
||||
# if encryption is not possible
|
||||
else
|
||||
@$('.js-securityEncrypt').attr('disabled', true)
|
||||
@$('.js-securityEncrypt').removeClass('btn--active')
|
||||
|
||||
@$('.js-securitySignComment').attr('title', data.sign.comment)
|
||||
|
||||
# if sign is possible
|
||||
if data.sign.success is true
|
||||
@$('.js-securitySign').attr('disabled', false)
|
||||
|
||||
# overrule current selection with Group configuration
|
||||
if selected.sign
|
||||
@$('.js-securitySign').addClass('btn--active')
|
||||
else
|
||||
@$('.js-securitySign').removeClass('btn--active')
|
||||
|
||||
# if sign is possible
|
||||
else
|
||||
@$('.js-securitySign').attr('disabled', true)
|
||||
@$('.js-securitySign').removeClass('btn--active')
|
||||
|
||||
error: (data) ->
|
||||
details = data.responseJSON || {}
|
||||
console.log(details)
|
||||
)
|
||||
@delay(callback, 200, 'security-check')
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<form>
|
||||
<h2><%- @T('Public & Private Keys') %></h2>
|
||||
<div class="settings-entry settings-entry--stretched js-keyList"></div>
|
||||
|
||||
<div class="btn btn--primary js-addPublicKey"><%- @T('Add Public Key') %></div>
|
||||
<div class="btn js-addPrivateKey"><%- @T('Add Private Key') %></div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2><%- @T('Default Behavior') %></h2>
|
||||
<p>Choose the default behavior of the PGP integration on per group basis. If signing or encrypting is not possible, the setting has no effect. Agents call always manually alter the behavior for each article.</p>
|
||||
<div class="settings-entry settings-entry--stretched js-groupList"></div>
|
||||
<div class="btn btn--primary js-updateGroup"><%- @T('Update') %></div>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<table class="settings-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="55%"><%- @T('Group') %>
|
||||
<th><%- @T('Sign') %>
|
||||
<th><%- @T('Encryption') %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if _.isEmpty(@groups): %>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<%- @T('No Entries') %>
|
||||
</td>
|
||||
</tr>
|
||||
<% else: %>
|
||||
<% for group in @groups: %>
|
||||
<tr data-id="<%= group.id %>">
|
||||
<td><%= group.name %>
|
||||
<td class="js-signDefault">
|
||||
<td class="js-encryptionDefault">
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<table class="settings-list settings-list--stretch">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="35%"><%- @T('Email') %>
|
||||
<th width="60%"><%- @T('Fingerprint') %>
|
||||
<th width="5%"><%- @T('Actions') %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if _.isEmpty(@keyPairs): %>
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<%- @T('No Entries') %>
|
||||
</td>
|
||||
</tr>
|
||||
<% else: %>
|
||||
<% for keyPair in @keyPairs: %>
|
||||
<tr data-id="<%= keyPair.id %>">
|
||||
<td><% if !_.isEmpty(keyPair.email_addresses): %><%= keyPair.email_addresses.toString() %><% end %>
|
||||
<% if keyPair.private_key: %><br><i><%- @T('Including private key.') %></i><% end %>
|
||||
<td title="<%= keyPair.fingerprint %>"><%= keyPair.fingerprint %>
|
||||
<td>
|
||||
<div class="dropdown dropdown--actions">
|
||||
<div class="btn btn--table btn--text btn--secondary js-action" data-toggle="dropdown">
|
||||
<%- @Icon('overflow-button') %>
|
||||
</div>
|
||||
<ul class="dropdown-menu dropdown-menu-right js-table-action-menu" role="menu">
|
||||
<% if keyPair.private_key: %>
|
||||
<li role="presentation" data-table-action="download-private">
|
||||
<a href="<%= @C('http_type') %>://<%= @C('fqdn')%>/api/v1/integration/pgp/private_key_download/<%= keyPair.id %>" download><%- @Icon('download') %> <%- @T('Download Private Key') %></a>
|
||||
</li>
|
||||
<% end %>
|
||||
<li role="presentation" data-table-action="download-public">
|
||||
<a href="<%= @C('http_type') %>://<%= @C('fqdn')%>/api/v1/integration/pgp/public_key_download/<%= keyPair.id %>"%download><%- @Icon('download') %> <%- @T('Download Public Key') %></a>
|
||||
</li>
|
||||
<li role="presentation" class="danger js-remove" data-table-action="remove">
|
||||
<%- @Icon('trash') %> <%- @T('Delete') %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<div>
|
||||
<p class="alert alert--danger js-error hide"></p>
|
||||
|
||||
<div class="form-field-group">
|
||||
<div class="form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="private_key-upload"><%- @T('Upload Private Key') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input name="file" type="file" id="private_key-upload">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="or-divider">
|
||||
<span><%- @T('or') %></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="private_key-paste"><%- @T('Paste Private Key') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<textarea cols="25" rows="20" name="data" style="height: 200px;"
|
||||
id="private_key-paste"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="private_key-secret"><%- @T('Enter Private Key Secret') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input class="form-control" name="secret" type="password" id="private_key-secret">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<div>
|
||||
<p class="alert alert--danger js-error hide"></p>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="public_key-upload"><%- @T('Upload Public Key') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input name="file" type="file" id="public_key-upload">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="or-divider">
|
||||
<span><%- @T('or') %></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="public_key-paste"><%- @T('Paste Public Key') %></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<textarea cols="25" rows="20" name="data" style="height: 200px;"
|
||||
id="public_key-paste"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1 @@
|
|||
.icon-pgp { width:17px; height: 17px; }
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Integration::PGPController < ApplicationController
|
||||
prepend_before_action { authentication_check && authorize! }
|
||||
|
||||
def public_key_download
|
||||
cert = PGPKeypair.find(params[:id])
|
||||
|
||||
send_data(
|
||||
cert.public_key,
|
||||
filename: "#{cert.fingerprint}.asc",
|
||||
type: 'text/plain',
|
||||
disposition: 'attachment'
|
||||
)
|
||||
end
|
||||
|
||||
def private_key_download
|
||||
cert = PGPKeypair.find(params[:id])
|
||||
|
||||
send_data(
|
||||
cert.private_key,
|
||||
filename: "#{cert.fingerprint}-private.asc",
|
||||
type: 'text/plain',
|
||||
disposition: 'attachment'
|
||||
)
|
||||
end
|
||||
|
||||
def public_key_list
|
||||
render json: PGPKeypair.all, methods: :email_addresses
|
||||
end
|
||||
|
||||
def public_key_delete
|
||||
PGPKeypair.find(params[:id]).destroy!
|
||||
render json: {
|
||||
result: 'ok'
|
||||
}
|
||||
end
|
||||
|
||||
def public_key_add
|
||||
string = params[:data]
|
||||
string = params[:file].read.force_encoding('utf-8') if string.blank? && params[:file].present?
|
||||
|
||||
items = PGPKeypair.create_public_keys(string)
|
||||
|
||||
render json: {
|
||||
result: 'ok',
|
||||
response: items
|
||||
}
|
||||
rescue StandardError => e
|
||||
unprocessable_entity(e)
|
||||
end
|
||||
|
||||
def private_key_delete
|
||||
PGPKeypair.find(params[:id]).update!(
|
||||
private_key: nil,
|
||||
private_key_secret: nil
|
||||
)
|
||||
|
||||
render json: {
|
||||
result: 'ok'
|
||||
}
|
||||
end
|
||||
|
||||
def private_key_add
|
||||
string = params[:data]
|
||||
string = params[:file].read.force_encoding('utf-8') if string.blank? && params[:file].present?
|
||||
|
||||
raise "Parameter 'data' or 'file' required." if string.blank?
|
||||
|
||||
PGPKeypair.create_private_keys(string, params[:secret])
|
||||
|
||||
render json: {
|
||||
result: 'ok'
|
||||
}
|
||||
rescue StandardError => e
|
||||
unprocessable_entity(e)
|
||||
end
|
||||
|
||||
def search
|
||||
result = {
|
||||
type: 'PGP'
|
||||
}
|
||||
|
||||
result[:encryption] = article_encryption(params[:article])
|
||||
result[:sign] = article_sign(params[:ticket])
|
||||
|
||||
render json: result
|
||||
end
|
||||
|
||||
def article_encryption(article)
|
||||
result = {
|
||||
success: false,
|
||||
comment: 'no recipient found'
|
||||
}
|
||||
|
||||
return result if article.blank?
|
||||
return result if article[:to].blank? && article[:cc].blank?
|
||||
|
||||
recipient = [article[:to], article[:cc]].compact.join(',').to_s
|
||||
recipients = []
|
||||
begin
|
||||
list = Mail::AddressList.new(recipient)
|
||||
list.addresses.each do |address|
|
||||
recipients.push address.address
|
||||
end
|
||||
rescue StandardError # rubocop:disable Lint/SuppressedException
|
||||
end
|
||||
|
||||
return result if recipients.blank?
|
||||
|
||||
begin
|
||||
keys = PGPKeypair.for_recipient_email_addresses!(recipients)
|
||||
|
||||
if keys
|
||||
result[:success] = true
|
||||
result[:comment] = "keys found for #{recipients.join(',')}"
|
||||
end
|
||||
rescue StandardError => e
|
||||
result[:comment] = e.message
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def article_sign(ticket)
|
||||
result = {
|
||||
success: false,
|
||||
comment: 'key not found'
|
||||
}
|
||||
|
||||
return result if ticket.blank? || !ticket[:group_id]
|
||||
|
||||
group = Group.find_by(id: ticket[:group_id])
|
||||
return result unless group
|
||||
|
||||
email_address = group.email_address
|
||||
begin
|
||||
list = Mail::AddressList.new(email_address.email)
|
||||
from = list.addresses.first.to_s
|
||||
key = PGPKeypair.for_sender_email_address(from)
|
||||
if key
|
||||
result[:success] = true
|
||||
result[:comment] = "key for #{email_address.email} found"
|
||||
else
|
||||
result[:success] = false
|
||||
result[:comment] = "no key for #{email_address.email} found"
|
||||
end
|
||||
rescue StandardError => e
|
||||
result[:comment] = e.message
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
64
packages/zammad-addon-pgp/src/app/models/pgp_keypair.rb
Normal file
64
packages/zammad-addon-pgp/src/app/models/pgp_keypair.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class PGPKeypair < ApplicationModel
|
||||
validates :fingerprint, uniqueness: { case_sensitive: true }
|
||||
|
||||
def self.create_private_keys(raw, secret)
|
||||
Sequoia.emails_of(keys: raw).each do |address|
|
||||
downcased_address = address.downcase
|
||||
public_key = find_each.detect do |certificate|
|
||||
certificate.email_addresses.include?(downcased_address)
|
||||
end
|
||||
|
||||
unless public_key
|
||||
raise Exceptions::UnprocessableEntity,
|
||||
'The public key for this private key could not be found.'
|
||||
end
|
||||
|
||||
public_key.update!(private_key: raw, private_key_secret: secret)
|
||||
end
|
||||
end
|
||||
|
||||
def self.create_public_keys(raw)
|
||||
create!(public_key: raw)
|
||||
end
|
||||
|
||||
def self.for_sender_email_address(address)
|
||||
downcased_address = address.downcase
|
||||
where.not(private_key: nil).find_each.detect do |certificate|
|
||||
certificate.email_addresses.include?(downcased_address)
|
||||
end
|
||||
end
|
||||
|
||||
def self.for_recipient_email_addresses!(addresses)
|
||||
certificates = []
|
||||
remaining_addresses = addresses.map(&:downcase)
|
||||
find_each do |certificate|
|
||||
# intersection of both lists
|
||||
certificate_for = certificate.email_addresses & remaining_addresses
|
||||
next if certificate_for.blank?
|
||||
|
||||
certificates.push(certificate)
|
||||
|
||||
# subtract found recipient(s)
|
||||
remaining_addresses -= certificate_for
|
||||
|
||||
# end loop if no addresses are remaining
|
||||
break if remaining_addresses.blank?
|
||||
end
|
||||
|
||||
return certificates if remaining_addresses.blank?
|
||||
|
||||
raise ActiveRecord::RecordNotFound,
|
||||
"Can't find PGP encryption certificates for: #{remaining_addresses.join(', ')}"
|
||||
end
|
||||
|
||||
def public_key=(string)
|
||||
self.fingerprint = Sequoia.fingerprints_of(keys: string).first
|
||||
self[:public_key] = string
|
||||
end
|
||||
|
||||
def email_addresses
|
||||
@email_addresses ||= Sequoia.emails_of(keys: public_key)
|
||||
end
|
||||
end
|
||||
1909
packages/zammad-addon-pgp/src/app/models/ticket.rb
Normal file
1909
packages/zammad-addon-pgp/src/app/models/ticket.rb
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,6 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Controllers::Integration::PGPControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
permit! :search, to: 'ticket.agent'
|
||||
default_permit!('admin.integration.pgp')
|
||||
end
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# Add new inflection rules using the following format. Inflections
|
||||
# are locale specific, and you may define rules for as many different
|
||||
# locales as you wish. All of these examples are active by default:
|
||||
# ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||
# inflect.plural /^(ox)$/i, '\1en'
|
||||
# inflect.singular /^(ox)en/i, '\1'
|
||||
# inflect.irregular 'person', 'people'
|
||||
# inflect.uncountable %w( fish sheep )
|
||||
# end
|
||||
|
||||
# These inflection rules are supported but not enabled by default:
|
||||
# ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||
# inflect.acronym 'RESTful'
|
||||
# end
|
||||
|
||||
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
||||
|
||||
# Rails thinks the singularized version of knowledge_bases is knowledge_basis?!
|
||||
# see: KnowledgeBase.table_name.singularize
|
||||
inflect.irregular 'base', 'bases'
|
||||
inflect.acronym 'SMIME'
|
||||
inflect.acronym 'PGP'
|
||||
inflect.acronym 'GitLab'
|
||||
inflect.acronym 'GitHub'
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
require 'ruby_openpgp'
|
||||
|
||||
Rails.application.config.before_configuration do
|
||||
#FIXME need icon
|
||||
icon = File.read("public/assets/images/icons/pgp.svg")
|
||||
doc = File.open("public/assets/images/icons.svg") { |f| Nokogiri::XML(f) }
|
||||
if !doc.at_css('#icon-pgp')
|
||||
doc.at('svg').add_child(icon)
|
||||
Rails.logger.debug "PGP support icon added to icon set"
|
||||
else
|
||||
Rails.logger.debug "PGP support icon already in icon set"
|
||||
end
|
||||
File.write("public/assets/images/icons.svg", doc.to_xml)
|
||||
end
|
||||
|
||||
# Rails.application.config.after_initialize do
|
||||
# Ticket::Article.add_observer Observer::Ticket::Article::CommunicatePgpSupport.instance
|
||||
# end
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match api_path + '/integration/pgp', to: 'integration/pgp#search', via: :post
|
||||
match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_add', via: :post
|
||||
match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_delete', via: :delete
|
||||
match api_path + '/integration/pgp/public_key', to: 'integration/pgp#public_key_list', via: :get
|
||||
match api_path + '/integration/pgp/private_key', to: 'integration/pgp#private_key_add', via: :post
|
||||
match api_path + '/integration/pgp/private_key', to: 'integration/pgp#private_key_delete', via: :delete
|
||||
match api_path + '/integration/pgp/public_key_download/:id', to: 'integration/pgp#public_key_download', via: :get
|
||||
match api_path + '/integration/pgp/private_key_download/:id', to: 'integration/pgp#private_key_download', via: :get
|
||||
end
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
class PGPSupport < ActiveRecord::Migration[5.2]
|
||||
def up
|
||||
# return if it's a new setup
|
||||
# return unless Setting.exists?(name: 'system_init_done')
|
||||
|
||||
Setting.create_if_not_exists(
|
||||
title: 'PGP integration',
|
||||
name: 'pgp_integration',
|
||||
area: 'Integration::Switch',
|
||||
description: 'Defines if PGP encryption is enabled or not.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'pgp_integration',
|
||||
tag: 'boolean',
|
||||
options: {
|
||||
true => 'yes',
|
||||
false => 'no'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
state: false,
|
||||
preferences: {
|
||||
prio: 1,
|
||||
authentication: true,
|
||||
permission: ['admin.integration']
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: 'PGP config',
|
||||
name: 'pgp_config',
|
||||
area: 'Integration::PGP',
|
||||
description: 'Defines the PGP config.',
|
||||
options: {},
|
||||
state: {},
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration']
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Defines postmaster filter.',
|
||||
name: '0016_postmaster_filter_smime',
|
||||
area: 'Postmaster::PreFilter',
|
||||
description: 'Defines postmaster filter to handle secure mailing.',
|
||||
options: {},
|
||||
state: 'Channel::Filter::SecureMailing',
|
||||
frontend: false
|
||||
)
|
||||
|
||||
create_table :pgp_keypairs do |t|
|
||||
t.string :fingerprint, limit: 250, null: false
|
||||
t.binary :public_key, limit: 10.megabytes, null: false
|
||||
t.binary :private_key, limit: 10.megabytes, null: true
|
||||
t.string :private_key_secret, limit: 500, null: true
|
||||
t.timestamps limit: 3, null: false
|
||||
end
|
||||
add_index :pgp_keypairs, [:fingerprint], unique: true
|
||||
end
|
||||
end
|
||||
4832
packages/zammad-addon-pgp/src/db/seeds/settings.rb
Normal file
4832
packages/zammad-addon-pgp/src/db/seeds/settings.rb
Normal file
File diff suppressed because it is too large
Load diff
7
packages/zammad-addon-pgp/src/lib/secure_mailing/pgp.rb
Normal file
7
packages/zammad-addon-pgp/src/lib/secure_mailing/pgp.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class SecureMailing::PGP < SecureMailing::Backend
|
||||
def self.active?
|
||||
Setting.get('pgp_integration')
|
||||
end
|
||||
end
|
||||
170
packages/zammad-addon-pgp/src/lib/secure_mailing/pgp/incoming.rb
Normal file
170
packages/zammad-addon-pgp/src/lib/secure_mailing/pgp/incoming.rb
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class SecureMailing::PGP::Incoming < SecureMailing::Backend::Handler
|
||||
attr_accessor :mail, :content_type
|
||||
|
||||
EXPRESSION_ENCRYPTED = %r{application/pgp-encrypted}i.freeze
|
||||
EXPRESSION_SIGNATURE = %r{application/pgp-signature}i.freeze
|
||||
|
||||
def initialize(mail)
|
||||
super()
|
||||
|
||||
@mail = mail
|
||||
@content_type = mail[:mail_instance].content_type
|
||||
end
|
||||
|
||||
def process
|
||||
return unless process?
|
||||
|
||||
initialize_article_preferences
|
||||
decrypt
|
||||
verify_signature
|
||||
log
|
||||
end
|
||||
|
||||
def initialize_article_preferences
|
||||
article_preferences[:security] = {
|
||||
type: 'PGP',
|
||||
sign: {
|
||||
success: false,
|
||||
comment: nil
|
||||
},
|
||||
encryption: {
|
||||
success: false,
|
||||
comment: nil
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def article_preferences
|
||||
@article_preferences ||= begin
|
||||
key = :'x-zammad-article-preferences'
|
||||
mail[key] ||= {}
|
||||
mail[key]
|
||||
end
|
||||
end
|
||||
|
||||
def process?
|
||||
signed? || encrypted?
|
||||
end
|
||||
|
||||
def signed?(check_content_type = content_type)
|
||||
EXPRESSION_SIGNATURE.match?(check_content_type)
|
||||
end
|
||||
|
||||
def encrypted?(check_content_type = content_type)
|
||||
EXPRESSION_ENCRYPTED.match?(check_content_type)
|
||||
end
|
||||
|
||||
def decrypt
|
||||
return unless encrypted?
|
||||
|
||||
success = false
|
||||
comment = 'Private key for decryption could not be found.'
|
||||
::PGPKeypair.where.not(private_key: [nil, '']).find_each do |cert|
|
||||
begin
|
||||
index = mail[:attachments].index { |file| file[:preferences]['Content-Type'] == 'application/pgp-encrypted' }
|
||||
data = mail[:attachments][index + 1][:data]
|
||||
decrypted_data = Sequoia.decrypt_for(ciphertext: data.chop, recipient: cert.private_key,
|
||||
password: cert.private_key_secret)
|
||||
rescue StandardError
|
||||
next
|
||||
end
|
||||
|
||||
parse_new_mail(decrypted_data)
|
||||
|
||||
success = true
|
||||
comment = cert.email_addresses.join(', ')
|
||||
|
||||
# overwrite content_type for signature checking
|
||||
@content_type = mail[:mail_instance].content_type
|
||||
break
|
||||
end
|
||||
|
||||
article_preferences[:security][:encryption] = {
|
||||
success: success,
|
||||
comment: comment
|
||||
}
|
||||
end
|
||||
|
||||
def verify_signature
|
||||
return unless signed?
|
||||
|
||||
success = false
|
||||
comment = 'Certificate for verification could not be found.'
|
||||
|
||||
::PGPKeypair.where.not(public_key: [nil, '']).find_each do |cert|
|
||||
next unless cert.email_addresses.include? mail[:from_email]
|
||||
|
||||
begin
|
||||
index = mail[:attachments].index { |file| file[:preferences]['Mime-Type'] == 'application/pgp-signature' }
|
||||
data = mail[:attachments][index][:data]
|
||||
verified_data = Sequoia.verify_detached_from(plaintext: mail[:mail_instance].body.encoded, signature: data.chop,
|
||||
sender: cert.public_key)
|
||||
rescue StandardError
|
||||
next
|
||||
end
|
||||
|
||||
parse_new_mail(verified_data)
|
||||
|
||||
success = true
|
||||
comment = cert.email_addresses.join(', ')
|
||||
|
||||
# overwrite content_type for signature checking
|
||||
@content_type = mail[:mail_instance].content_type
|
||||
break
|
||||
end
|
||||
|
||||
article_preferences[:security][:sign] = {
|
||||
success: success,
|
||||
comment: comment
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log
|
||||
%i[sign encryption].each do |action|
|
||||
result = article_preferences[:security][action]
|
||||
next if result.blank?
|
||||
|
||||
if result[:success]
|
||||
status = 'success'
|
||||
elsif result[:comment].blank?
|
||||
# means not performed
|
||||
next
|
||||
else
|
||||
status = 'failed'
|
||||
end
|
||||
|
||||
HttpLog.create(
|
||||
direction: 'in',
|
||||
facility: 'PGP',
|
||||
url: "#{mail[:from_email]} -> #{mail[:to]}",
|
||||
status: status,
|
||||
ip: nil,
|
||||
request: {
|
||||
message_id: mail[:message_id]
|
||||
},
|
||||
response: article_preferences[:security],
|
||||
method: action,
|
||||
created_by_id: 1,
|
||||
updated_by_id: 1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_new_mail(new_mail)
|
||||
mail[:mail_instance].header['Content-Type'] = nil
|
||||
mail[:mail_instance].header['Content-Disposition'] = nil
|
||||
mail[:mail_instance].header['Content-Transfer-Encoding'] = nil
|
||||
mail[:mail_instance].header['Content-Description'] = nil
|
||||
|
||||
new_raw_mail = "#{mail[:mail_instance].header}#{new_mail}"
|
||||
|
||||
mail_new = Channel::EmailParser.new.parse(new_raw_mail)
|
||||
mail_new.each do |local_key, local_value|
|
||||
mail[local_key] = local_value
|
||||
end
|
||||
end
|
||||
end
|
||||
120
packages/zammad-addon-pgp/src/lib/secure_mailing/pgp/outgoing.rb
Normal file
120
packages/zammad-addon-pgp/src/lib/secure_mailing/pgp/outgoing.rb
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class SecureMailing::PGP::Outgoing < SecureMailing::Backend::Handler
|
||||
def initialize(mail, security)
|
||||
super()
|
||||
|
||||
@mail = mail
|
||||
@security = security
|
||||
end
|
||||
|
||||
def process
|
||||
return unless process?
|
||||
|
||||
if @security[:sign][:success]
|
||||
sign
|
||||
log('sign', 'success')
|
||||
end
|
||||
if @security[:encryption][:success]
|
||||
encrypt
|
||||
log('encryption', 'success')
|
||||
end
|
||||
end
|
||||
|
||||
def process?
|
||||
return false if @security.blank?
|
||||
return false if @security[:type] != 'PGP'
|
||||
|
||||
@security[:sign][:success] || @security[:encryption][:success]
|
||||
end
|
||||
|
||||
def cleanup(mail)
|
||||
part = Mail::Part.new
|
||||
if mail.multipart?
|
||||
if mail.content_type =~ /^(multipart[^;]+)/
|
||||
part.content_type Regexp.last_match(1)
|
||||
else
|
||||
part.content_type 'multipart/mixed'
|
||||
end
|
||||
mail.body.parts.each do |p|
|
||||
part.add_part cleanup(p)
|
||||
end
|
||||
else
|
||||
# retain important headers if present
|
||||
part.content_type mail.content_type
|
||||
part.content_id mail.header['Content-ID'] if mail.header['Content-ID']
|
||||
part.content_disposition mail.content_disposition if mail.content_disposition
|
||||
|
||||
# force base64 encoding
|
||||
part.body Mail::Encodings::Base64.encode(mail.body.to_s)
|
||||
part.body.encoding = 'base64'
|
||||
end
|
||||
part
|
||||
end
|
||||
|
||||
def sign
|
||||
from = @mail.from.first
|
||||
cert = PGPKeypair.for_sender_email_address(from)
|
||||
raise "Unable to find PGP private key for '#{from}'" unless cert
|
||||
|
||||
signature = Sequoia.sign_detached_with(plaintext: @mail.body.encoded, sender: cert.private_key,
|
||||
password: cert.private_key_secret)
|
||||
|
||||
signature_part = Mail::Part.new do
|
||||
content_type 'application/pgp-signature; name="signature.asc"'
|
||||
content_disposition 'attachment; filename="signature.asc"'
|
||||
content_description 'OpenPGP signature'
|
||||
body signature
|
||||
end
|
||||
@mail.add_part signature_part
|
||||
@mail.content_type "multipart/signed; protocol=\"application/pgp-signature\"; micalg=\"pgp-sha512\"; boundary=\"#{@mail.boundary}\""
|
||||
rescue StandardError => e
|
||||
log('sign', 'failed', e.message)
|
||||
raise
|
||||
end
|
||||
|
||||
def encrypt
|
||||
recipients = []
|
||||
recipients += @mail.to if @mail.to
|
||||
recipients += @mail.cc if @mail.cc
|
||||
recipients += @mail.bcc if @mail.bcc
|
||||
|
||||
certificates = PGPKeypair.for_recipient_email_addresses!(recipients)
|
||||
|
||||
encrypted_control = Mail::Part.new do
|
||||
content_type 'application/pgp-encrypted'
|
||||
content_description 'OpenPGP version'
|
||||
body 'Version: 1'
|
||||
end
|
||||
|
||||
plaintext = @mail.encoded
|
||||
encrypted_part = Mail::Part.new do
|
||||
content_type 'application/octet-stream; name="encrypted.asc"'
|
||||
content_disposition 'inline; filename="encrypted.asc"'
|
||||
content_description 'OpenPGP encrypted message'
|
||||
body Sequoia.encrypt_for(plaintext: plaintext, recipients: certificates.map(&:public_key))
|
||||
end
|
||||
@mail.body = nil
|
||||
@mail.add_part encrypted_control
|
||||
@mail.add_part encrypted_part
|
||||
@mail.content_type "multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"#{@mail.boundary}\""
|
||||
rescue StandardError => e
|
||||
log('encryption', 'failed', e.message)
|
||||
raise
|
||||
end
|
||||
|
||||
def log(action, status, error = nil)
|
||||
HttpLog.create(
|
||||
direction: 'out',
|
||||
facility: 'PGP',
|
||||
url: "#{@mail[:from_email]} -> #{@mail[:to]}",
|
||||
status: status,
|
||||
ip: nil,
|
||||
request: @security,
|
||||
response: { error: error },
|
||||
method: action,
|
||||
created_by_id: 1,
|
||||
updated_by_id: 1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class SecureMailing::PGP::Retry < SecureMailing::Backend::Handler
|
||||
def initialize(article)
|
||||
super()
|
||||
@article = article
|
||||
end
|
||||
|
||||
def process
|
||||
return existing_result if already_processed?
|
||||
|
||||
save_result if retry_succeeded?
|
||||
retry_result
|
||||
end
|
||||
|
||||
def signature_checked?
|
||||
@signature_checked ||= existing_result&.dig('sign', 'success') || false
|
||||
end
|
||||
|
||||
def decrypted?
|
||||
@decrypted ||= existing_result&.dig('encryption', 'success') || false
|
||||
end
|
||||
|
||||
def already_processed?
|
||||
signature_checked? && decrypted?
|
||||
end
|
||||
|
||||
def existing_result
|
||||
@article.preferences['security']
|
||||
end
|
||||
|
||||
def mail
|
||||
@mail ||= begin
|
||||
raw_mail = @article.as_raw.store_file.content
|
||||
Channel::EmailParser.new.parse(raw_mail).tap do |parsed|
|
||||
SecureMailing.incoming(parsed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def retry_result
|
||||
@retry_result ||= mail['x-zammad-article-preferences']['security']
|
||||
end
|
||||
|
||||
def signature_found?
|
||||
return false if signature_checked?
|
||||
|
||||
retry_result['sign']['success']
|
||||
end
|
||||
|
||||
def decryption_succeeded?
|
||||
return false if decrypted?
|
||||
|
||||
retry_result['encryption']['success']
|
||||
end
|
||||
|
||||
def retry_succeeded?
|
||||
return true if signature_found?
|
||||
|
||||
decryption_succeeded?
|
||||
end
|
||||
|
||||
def save_result
|
||||
save_decrypted if decryption_succeeded?
|
||||
@article.preferences['security'] = retry_result
|
||||
@article.save!
|
||||
end
|
||||
|
||||
def save_decrypted
|
||||
@article.content_type = mail['content_type']
|
||||
@article.body = mail['body']
|
||||
|
||||
Store.remove(
|
||||
object: 'Ticket::Article',
|
||||
o_id: @article.id
|
||||
)
|
||||
|
||||
mail[:attachments]&.each do |attachment|
|
||||
filename = attachment[:filename].force_encoding('utf-8')
|
||||
unless filename.force_encoding('UTF-8').valid_encoding?
|
||||
filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
|
||||
end
|
||||
Store.add(
|
||||
object: 'Ticket::Article',
|
||||
o_id: @article.id,
|
||||
data: attachment[:data],
|
||||
filename: filename,
|
||||
preferences: attachment[:preferences],
|
||||
created_by_id: @article.created_by_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M512 176a176 176 0 01-208.8 173l-24 27a24 24 0 01-18 8H224v40a24 24 0 01-24 24h-40v40a24 24 0 01-24 24H24a24 24 0 01-24-24v-78a24 24 0 017-17l161.8-161.8A176 176 0 11512 176zm-176-48a48 48 0 1096 0 48 48 0 00-96 0z"/><script/></svg>
|
||||
|
After Width: | Height: | Size: 304 B |
Loading…
Add table
Add a link
Reference in a new issue