Controller for Icon metadata

closes MAT-902
flag=none

Test plan
- Having an icon attachment id
  access /api/v1/files/6610/icon_metadata
  and ensure you can get the icon metadata
- In the Rich Content Editor, ensure you
  can create and edit icons successfully
  using the metadata endpoint

Change-Id: I44ddc1d9187e2e1ce8fd194f6aa4826047c10e3f
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/297548
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com>
QA-Review: Jacob DeWar <jacob.dewar@instructure.com>
Product-Review: Mysti Lilla <mysti@instructure.com>
This commit is contained in:
Mysti Lilla 2022-07-20 17:26:56 -06:00
parent b1acfec0dd
commit 5205c27ed6
10 changed files with 314 additions and 231 deletions

View File

@ -136,7 +136,8 @@ class FilesController < ApplicationController
before_action :require_context, except: %i[
assessment_question_show image_thumbnail show_thumbnail
create_pending s3_success show api_create api_create_success api_create_success_cors
api_show api_index destroy api_update api_file_status public_url api_capture reset_verifier
api_show api_index destroy api_update api_file_status public_url api_capture icon_metadata
reset_verifier
]
before_action :open_limited_cors, only: [:show]
@ -148,7 +149,7 @@ class FilesController < ApplicationController
skip_before_action :verify_authenticity_token, only: :api_create
before_action :verify_api_id, only: %i[
api_show api_create_success api_file_status api_update destroy reset_verifier
api_show api_create_success api_file_status api_update destroy icon_metadata reset_verifier
]
include Api::V1::Attachment
@ -497,7 +498,7 @@ class FilesController < ApplicationController
# file was deleted and replaced by another.
#
# Indicates the context ID Canvas should use when following the "replacement chain." The
# "replacement_chain_context_id" paraamter must also be included.
# "replacement_chain_context_type" parameter must also be included.
#
# @example_request
#
@ -524,7 +525,7 @@ class FilesController < ApplicationController
end
params[:include] = Array(params[:include])
if read_allowed(@attachment, @current_user, session, params)
if access_allowed(@attachment, @current_user, :read, session, params)
json = attachment_json(@attachment, @current_user, {}, { include: params[:include], omit_verifier_in_app: !value_to_boolean(params[:use_verifiers]) })
# Add canvadoc session URL if the file is unlocked
@ -603,7 +604,7 @@ class FilesController < ApplicationController
return
end
if read_allowed(@attachment, @current_user, session, params)
if access_allowed(@attachment, @current_user, :read, session, params)
@attachment.ensure_media_object
verifier_checker = Attachments::Verification.new(@attachment)
@ -1308,6 +1309,86 @@ class FilesController < ApplicationController
end
end
# @API Get icon metadata
# Returns the icon maker file attachment metadata
#
# @example_request
#
# curl 'https://<canvas>/api/v1/courses/1/files/1/metadata' \
# -H 'Authorization: Bearer <token>'
#
# @example_response
#
# {
# "type":"image/svg+xml-icon-maker-icons",
# "alt":"",
# "shape":"square",
# "size":"small",
# "color":"#FFFFFF",
# "outlineColor":"#65499D",
# "outlineSize":"large",
# "text":"Hello",
# "textSize":"x-large",
# "textColor":"#65499D",
# "textBackgroundColor":"#FFFFFF",
# "textPosition":"bottom-third",
# "encodedImage":"data:image/svg+xml;base64,PH==",
# "encodedImageType":"SingleColor",
# "encodedImageName":"Health Icon",
# "x":"50%",
# "y":"50%",
# "translateX":-54,
# "translateY":-54,
# "width":108,
# "height":108,
# "transform":"translate(-54,-54)"
# }
#
def icon_metadata
@icon = Attachment.find(params[:id])
@icon = attachment_or_replacement(@icon.context, params[:id]) if @icon.deleted? && @icon.replacement_attachment_id.present?
return render json: { errors: [{ message: "The specified resource does not exist." }] }, status: :not_found if @icon.deleted?
return unless access_allowed(@icon, @current_user, :download, session, params)
unless @icon.category == Attachment::ICON_MAKER_ICONS
return render json: { errors: [{ message: "The requested attachment does not support viewing metadata." }] }, status: :bad_request
end
sax_doc = MetadataSaxDoc.new
parser = Nokogiri::XML::SAX::PushParser.new(sax_doc)
@icon.open do |chunk|
parser << chunk
break if sax_doc.metadata_value.present?
end
sax_doc.metadata_value.present? ? render(json: { name: @icon.display_name }.merge(JSON.parse(sax_doc.metadata_value))) : head(:no_content)
end
class MetadataSaxDoc < Nokogiri::XML::SAX::Document
attr_reader :current_value, :metadata_value, :retain_data
def start_element(name, _attrs)
return unless name == "metadata"
@current_value = ""
@retain_data = true
end
def end_element(name)
return unless name == "metadata"
@metadata_value = current_value
@retain_data = false
end
def characters(chars)
return unless retain_data
@current_value ||= ""
@current_value += chars
end
end
private_constant :MetadataSaxDoc
# @API Reset link verifier
#
# Resets the link verifier. Any existing links to the file using
@ -1426,19 +1507,19 @@ class FilesController < ApplicationController
headers["Access-Control-Allow-Methods"] = "GET, HEAD"
end
def read_allowed(attachment, user, session, params)
def access_allowed(attachment, user, access_type, session, params)
if params[:verifier]
verifier_checker = Attachments::Verification.new(attachment)
return true if verifier_checker.valid_verifier_for_permission?(params[:verifier], :read, session)
return true if verifier_checker.valid_verifier_for_permission?(params[:verifier], access_type, session)
end
submissions = attachment.attachment_associations.where(context_type: "Submission").preload(:context).filter_map(&:context)
return true if submissions.any? { |submission| submission.grants_right?(user, session, :read) }
return true if submissions.any? { |submission| submission.grants_right?(user, session, access_type) }
course = api_find(Assignment, params[:assignment_id]).course unless params[:assignment_id].nil?
return true if course&.grants_right?(user, session, :read)
course = api_find(Assignment, params[:assignment_id]).course if params[:assignment_id].present?
return true if course&.grants_right?(user, session, access_type)
authorized_action(attachment, user, :read)
authorized_action(attachment, user, access_type)
end
def strong_attachment_params

View File

@ -161,7 +161,7 @@ class Attachment < ActiveRecord::Base
after_save_and_attachment_processing :ensure_media_object
# this mixin can be added to a has_many :attachments association, and it'll
# handle finding replaced attachments. In other words, if an attachment fond
# handle finding replaced attachments. In other words, if an attachment found
# by id is deleted but an active attachment in the same context has the same
# path, it'll return that attachment.
module FindInContextAssociation

View File

@ -1766,6 +1766,7 @@ CanvasRails::Application.routes.draw do
# 'attachment' (rather than 'file') is used below so modules API can use polymorphic_url to generate an item API link
get "files/:id", action: :api_show, as: "attachment"
get "files/:id/icon_metadata", action: :icon_metadata
delete "files/:id", action: :destroy
put "files/:id", action: :api_update
post "files/:id/reset_verifier", action: :reset_verifier

View File

@ -158,6 +158,7 @@
"@testing-library/dom": "^8",
"@testing-library/jest-dom": "^5",
"@testing-library/react": "^12",
"@testing-library/react-hooks": "^5",
"@testing-library/user-event": "^12",
"axe-testcafe": "^3",
"babel-loader": "^8.0.0",

View File

@ -85,7 +85,9 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
})
afterEach(async () => {
await act(async() => { jest.runOnlyPendingTimers() })
await act(async () => {
jest.runOnlyPendingTimers()
})
})
it('renders the create view', () => {
@ -140,7 +142,7 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
})
beforeEach(() => {
window.HTMLElement.prototype.focus = jest.fn().mockImplementation(function (args) {
window.HTMLElement.prototype.focus = jest.fn().mockImplementation(function (_args) {
focusedElement = this
})
})
@ -442,24 +444,20 @@ describe('RCE "Icon Maker" Plugin > IconMakerTray', () => {
beforeEach(() => {
fetchMock.mock('*', {
body: `
<svg height="100" width="100">
<metadata>
{
"alt":"a test image",
"shape":"triangle",
"size":"large",
"color":"#FF2717",
"outlineColor":"#06A3B7",
"outlineSize":"small",
"text":"Some Text",
"textSize":"medium",
"textColor":"#009606",
"textBackgroundColor":"#E71F63",
"textPosition":"below"
}
</metadata>
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
</svg>`
{
"name":"Test Icon.svg",
"alt":"a test image",
"shape":"triangle",
"size":"large",
"color":"#FF2717",
"outlineColor":"#06A3B7",
"outlineSize":"small",
"text":"Some Text",
"textSize":"medium",
"textColor":"#009606",
"textBackgroundColor":"#E71F63",
"textPosition":"below"
}`
})
})

View File

@ -18,11 +18,8 @@
import fetchMock from 'fetch-mock'
import {renderHook, act} from '@testing-library/react-hooks/dom'
import {useSvgSettings, svgFromUrl, statuses} from '../settings'
import {useSvgSettings, statuses} from '../settings'
import Editor from '../../../shared/__tests__/FakeEditor'
import RceApiSource from '../../../../../rcs/api'
jest.mock('../../../../../rcs/api')
describe('useSvgSettings()', () => {
let editing, ed, rcs
@ -30,16 +27,12 @@ describe('useSvgSettings()', () => {
beforeEach(() => {
ed = new Editor()
rcs = {
getFile: jest.fn(() => Promise.resolve({name: 'Test Icon.svg'})),
contextType: 'course',
contextId: 1,
canvasUrl: 'https://domain.from.env'
}
RceApiSource.mockImplementation(() => rcs)
})
afterEach(() => RceApiSource.mockClear())
const subject = () => renderHook(() => useSvgSettings(ed, editing, rcs)).result
describe('when a new icon is being created (not editing)', () => {
@ -166,10 +159,8 @@ describe('useSvgSettings()', () => {
// Icon Maker icons that have it
//
body = `
<svg height="100" width="100">
<metadata>
{
"name":"Test Image",
"name":"Test Icon",
"alt":"a test image",
"shape":"triangle",
"size":"large",
@ -181,14 +172,11 @@ describe('useSvgSettings()', () => {
"textColor":"#009606",
"textBackgroundColor":"#06A3B7",
"textPosition":"below"
}
</metadata>
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
</svg>`
}`
// Stub fetch to return an SVG file
mock = fetchMock.mock({
name: 'download-url',
name: 'icon_metadata',
matcher: '*',
response: () => ({body})
})
@ -199,12 +187,12 @@ describe('useSvgSettings()', () => {
fetchMock.restore()
})
it('fetches the SVG file, specifying the course ID and timestamp', () => {
it('fetches the icon metadata, specifying the course ID and timestamp', () => {
subject()
expect(mock.called('download-url')).toBe(true)
expect(mock.calls('download-url')[0][0]).toMatch(
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
expect(mock.called('icon_metadata')).toBe(true)
expect(mock.calls('icon_metadata')[0][0]).toMatch(
/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/
)
})
@ -216,12 +204,12 @@ describe('useSvgSettings()', () => {
ed.setSelectedNode(ed.dom.select('#test-image')[0])
})
it('fetches the SVG file using the /files/:file_id/download endpoint', () => {
it('fetches the icon metadata using the /files/:file_id/icon_metadata endpoint', () => {
subject()
expect(mock.called('download-url')).toBe(true)
expect(mock.calls('download-url')[0][0]).toMatch(
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
expect(mock.called('icon_metadata')).toBe(true)
expect(mock.calls('icon_metadata')[0][0]).toMatch(
/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/
)
})
})
@ -234,12 +222,10 @@ describe('useSvgSettings()', () => {
ed.setSelectedNode(ed.dom.select('#test-image')[0])
})
it('fetches the SVG file, specifying the course ID and timestamp', () => {
it('fetches the icon metadata, specifying the course ID and timestamp', () => {
subject()
const calledUrl = mock.calls('download-url')[0][0]
expect(calledUrl).toMatch(
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
)
const calledUrl = mock.calls('icon_metadata')[0][0]
expect(calledUrl).toMatch(/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/)
})
})
@ -251,29 +237,14 @@ describe('useSvgSettings()', () => {
ed.setSelectedNode(ed.dom.select('#containing')[0])
})
it('fetches the SVG file, specifying the course ID and timestamp', () => {
it('fetches the icon metadata, specifying the course ID and timestamp', () => {
subject()
const calledUrl = mock.calls('download-url')[0][0]
expect(calledUrl).toMatch(
/https:\/\/domain.from.env\/files\/1\/download\?replacement_chain_context_type=course&replacement_chain_context_id=1&ts=\d+&download_frd=1/
)
const calledUrl = mock.calls('icon_metadata')[0][0]
expect(calledUrl).toMatch(/https:\/\/domain.from.env\/api\/v1\/files\/1\/icon_metadata/)
})
})
it('uses replacement chain context info in request for file name', async () => {
const {result, waitForValueToChange} = renderHook(() => useSvgSettings(ed, editing, rcs))
await waitForValueToChange(() => {
return result.current[0]
})
expect(rcs.getFile).toHaveBeenCalledWith('1', {
replacement_chain_context_id: 1,
replacement_chain_context_type: 'course'
})
})
it('parses the SVG settings from the SVG metadata', async () => {
it('parses the SVG settings from the icon metadata', async () => {
const {result, waitForValueToChange} = renderHook(() => useSvgSettings(ed, editing, rcs))
await waitForValueToChange(() => {
@ -317,41 +288,36 @@ describe('useSvgSettings()', () => {
describe('parses the SVG settings from a legacy SVG metadata structure', () => {
const bodyGenerator = overrideParams => `
<svg height="100" width="100">
<metadata>
${JSON.stringify({
...{
name: 'Test Image',
alt: 'a test image',
shape: 'triangle',
size: 'large',
color: '#FF2717',
outlineColor: '#06A3B7',
outlineSize: 'small',
text: 'Some Text',
textSize: 'medium',
textColor: '#009606',
textBackgroundColor: '#06A3B7',
textPosition: 'below',
imageSettings: {
cropperSettings: null,
icon: {
label: 'Art Icon'
},
iconFillColor: '#FFFFFF',
image: 'Art Icon',
mode: 'SingleColor'
}
${JSON.stringify({
...{
name: 'Test Icon',
alt: 'a test image',
shape: 'triangle',
size: 'large',
color: '#FF2717',
outlineColor: '#06A3B7',
outlineSize: 'small',
text: 'Some Text',
textSize: 'medium',
textColor: '#009606',
textBackgroundColor: '#06A3B7',
textPosition: 'below',
imageSettings: {
cropperSettings: null,
icon: {
label: 'Art Icon'
},
...overrideParams
})}
</metadata>
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
</svg>`
iconFillColor: '#FFFFFF',
image: 'Art Icon',
mode: 'SingleColor'
}
},
...overrideParams
})}`
const overwriteUrl = () =>
(mock = fetchMock.mock({
name: 'download-url',
name: 'icon_metadata',
matcher: '*',
response: () => ({body}),
overwriteRoutes: true
@ -642,48 +608,40 @@ describe('useSvgSettings()', () => {
data-download-url="https://canvas.instructure.com/files/2/download" />
`)
fetchMock.mock('begin:https://domain.from.env/files/1/download', {
fetchMock.mock('begin:https://domain.from.env/api/v1/files/1/icon_metadata', {
body: `
<svg height="100" width="100">
<metadata>
{
"alt":"the first test image",
"shape":"triangle",
"size":"large",
"color":"#FF2717",
"outlineColor":"#06A3B7",
"outlineSize":"small",
"text":"Some Text",
"textSize":"medium",
"textColor":"#009606",
"textBackgroundColor":"#06A3B7",
"textPosition":"below"
}
</metadata>
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
</svg>`
{
"name":"Test Icon.svg",
"alt":"the first test image",
"shape":"triangle",
"size":"large",
"color":"#FF2717",
"outlineColor":"#06A3B7",
"outlineSize":"small",
"text":"Some Text",
"textSize":"medium",
"textColor":"#009606",
"textBackgroundColor":"#06A3B7",
"textPosition":"below"
}`
})
fetchMock.mock('begin:https://domain.from.env/files/2/download', {
fetchMock.mock('begin:https://domain.from.env/api/v1/files/2/icon_metadata', {
body: `
<svg height="100" width="100">
<metadata>
{
"alt":"the second test image",
"shape":"square",
"size":"medium",
"color":"#FF2717",
"outlineColor":"#06A3B7",
"outlineSize":"small",
"text":"Some Text",
"textSize":"medium",
"textColor":"#009606",
"textBackgroundColor":"#06A3B7",
"textPosition":"below"
}
</metadata>
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
</svg>`
{
"name":"Test Icon.svg",
"alt":"the second test image",
"shape":"square",
"size":"medium",
"color":"#FF2717",
"outlineColor":"#06A3B7",
"outlineSize":"small",
"text":"Some Text",
"textSize":"medium",
"textColor":"#009606",
"textBackgroundColor":"#06A3B7",
"textPosition":"below"
}`
})
})
@ -707,47 +665,3 @@ describe('useSvgSettings()', () => {
})
})
})
describe('svgFromUrl()', () => {
let svgResponse
const subject = () => svgFromUrl('https://www.instructure.com/svg')
beforeEach(() => {
fetchMock.mock('https://www.instructure.com/svg', () => ({
body: svgResponse,
sendAsJson: false
}))
})
afterEach(() => {
fetchMock.restore()
jest.resetAllMocks()
})
describe('when the url points to an SVG file', () => {
beforeEach(() => {
svgResponse = `
<svg height="100" width="100">
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>
</svg>
`
})
it('returns the parsed SVG document', async () => {
const svgDoc = await subject()
expect(svgDoc.querySelector('svg').innerHTML).toContain(
'<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red"/>'
)
})
})
describe('when the url points to a document that is not parsable', () => {
beforeEach(() => (svgResponse = 'asdf'))
it('returns an empty document', async () => {
const doc = await subject()
expect(doc.firstChild.toString.innerHTML).toEqual(undefined)
})
})
})

View File

@ -19,7 +19,6 @@
import {useState, useEffect, useReducer} from 'react'
import {svgSettings as svgSettingsReducer, defaultState} from '../reducers/svgSettings'
import {ICON_MAKER_ATTRIBUTE, ICON_MAKER_DOWNLOAD_URL_ATTR, SVG_XML_TYPE} from './constants'
import RceApiSource from '../../../../rcs/api'
import {modes} from '../reducers/imageSection'
import iconsLabels from '../utils/iconsLabels'
@ -56,22 +55,10 @@ const getImageNode = (editor, editing) => {
return iconMaker
}
const buildFilesUrl = (fileId, rcsConfig) => {
// http://canvas.docker/files/2169/download?download_frd=1&amp;icon_maker_icon=1
const downloadURL = new URL(`${rcsConfig.canvasUrl}/files/${fileId}/download`)
// Adding the Course ID to the request causes Canvas to follow the chain
// of files that were uploaded and "replaced" previous versions of the file.
downloadURL.searchParams.append('replacement_chain_context_type', 'course')
downloadURL.searchParams.append('replacement_chain_context_id', rcsConfig.contextId)
// Prevent the browser from using an old cached SVGs
downloadURL.searchParams.append('ts', Date.now())
// Yes, we want do download for real dude
downloadURL.searchParams.append('download_frd', 1)
const buildMetadataUrl = (fileId, rcsConfig) => {
// http://canvas.docker/api/v1/files/2169/icon_metadata
const downloadURL = new URL(`${rcsConfig.canvasUrl}/api/v1/files/${fileId}/icon_metadata`)
return downloadURL.toString()
}
@ -97,25 +84,15 @@ export function useSvgSettings(editor, editing, rcsConfig) {
// Parse out the file ID from something like
// /courses/1/files/3/preview?...
const fileId = urlFromNode.split('files/')[1]?.split('/')[0]
const downloadUrl = buildFilesUrl(fileId, rcsConfig)
const downloadUrl = buildMetadataUrl(fileId, rcsConfig)
// Parse SVG. If no SVG found, return defaults
const svg = await svgFromUrl(downloadUrl)
if (!svg) return
// Parse metadata. If no metadata found, return defaults
const metadata = svg.querySelector('metadata')?.innerHTML
// Download icon metadata. If no metadata found, return defaults
const response = await fetch(downloadUrl)
const metadata = await response.text()
if (!metadata) return
const rcs = new RceApiSource(rcsConfig)
const fileData = await rcs.getFile(fileId, {
replacement_chain_context_type: rcsConfig.contextType,
replacement_chain_context_id: rcsConfig.contextId
})
const fileName = fileData.name.replace(/\.[^\.]+$/, '')
const metadataJson = JSON.parse(metadata)
const fileName = metadataJson.name.replace(/\.[^\.]+$/, '')
metadataJson.name = fileName
metadataJson.originalName = fileName
@ -152,13 +129,6 @@ export function useSvgSettings(editor, editing, rcsConfig) {
return [settings, status, dispatch]
}
export async function svgFromUrl(url) {
const response = await fetch(url)
const data = await response.text()
return new DOMParser().parseFromString(data, SVG_XML_TYPE)
}
function processMetadataForBackwardCompatibility(metadataJson) {
const icon = metadataJson?.imageSettings?.icon
const mode = metadataJson?.imageSettings?.mode

View File

@ -20,6 +20,7 @@
require_relative "../api_spec_helper"
require_relative "../locked_examples"
require "webmock/rspec"
RSpec.configure do |config|
config.include ApplicationHelper
@ -1410,6 +1411,102 @@ describe "Files API", type: :request do
end
end
describe "#icon_metadata" do
context "instfs file" do
before do
@root = Folder.root_folders(@course).first
@icon = Attachment.create!(filename: "icon.svg", display_name: "icon.svg", uploaded_data: File.open("spec/fixtures/icon.svg"),
folder: @root, context: @course, category: Attachment::ICON_MAKER_ICONS, instfs_uuid: "yes")
@file_path = "/api/v1/files/#{@icon.id}/icon_metadata"
@file_path_options = { controller: "files", action: "icon_metadata", format: "json", id: @icon.id.to_param }
allow(InstFS).to receive(:authenticated_url).and_return(@icon.authenticated_s3_url)
allow(CanvasHttp).to receive(:validate_url).and_return([@icon.authenticated_s3_url, URI.parse(@icon.authenticated_s3_url)])
stub_request(:get, @icon.authenticated_s3_url).to_return(body: File.open("spec/fixtures/icon.svg"))
end
it "returns metadata from the icon" do
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 200)
json = JSON.parse(response.body)
expect(json["type"]).to eq "image/svg+xml-icon-maker-icons"
expect(json["encodedImage"]).to be_starts_with "data:image/svg+xml;base64,PHN2ZyB3aWR0aD"
end
it "gives unauthorized errors if the user is not authorized to view the file" do
@icon.update(locked: true)
course_with_student_logged_in(course: @course)
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 401)
end
it "gives bad request errors if the file is not an icon" do
@icon.update(category: Attachment::UNCATEGORIZED)
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 400)
end
it "return 'no content' if the file doesn't have any metadata" do
stub_request(:get, @icon.public_url).to_return(body: "<html>something that doesn't have any metadata</html>")
raw_api_call(:get, @file_path, @file_path_options)
assert_status(204)
end
context "streaming" do
before do
# force chunking so streaming will actually act like a stream
mocked_http = Class.new(Net::HTTP) do
def request(*)
super do |response|
response.instance_eval do
def read_body(*, &block)
@body.each_char(&block)
end
end
yield response if block_given?
response
end
end
end
@original_net_http = Net.send(:remove_const, :HTTP)
Net.send(:const_set, :HTTP, mocked_http)
end
after do
Net.send(:remove_const, :HTTP)
Net.send(:const_set, :HTTP, @original_net_http)
end
it "only downloads data until the end of the metadata tag" do
# I cut most of the original icon file off so that the XML is invalid if you read the whole thing,
# but left enough that the metadata will be present and there will be a buffer for the http request
# to read without erroring unless it downloads/parses too much of the file
stub_request(:get, @icon.public_url).to_return(body: File.open("spec/fixtures/icon_with_bad_xml.svg"))
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 200)
json = JSON.parse(response.body)
expect(json["type"]).to eq "image/svg+xml-icon-maker-icons"
expect(json["encodedImage"]).to be_starts_with "data:image/svg+xml;base64,PHN2ZyB3aWR0aD"
end
end
end
context "local file" do
before do
@root = Folder.root_folders(@course).first
@icon = Attachment.create!(filename: "icon.svg", display_name: "icon.svg", uploaded_data: File.open("spec/fixtures/icon.svg"),
folder: @root, context: @course, category: Attachment::ICON_MAKER_ICONS)
@file_path = "/api/v1/files/#{@icon.id}/icon_metadata"
@file_path_options = { controller: "files", action: "icon_metadata", format: "json", id: @icon.id.to_param }
allow(CanvasHttp).to receive(:validate_url).and_return([@icon.authenticated_s3_url, URI.parse(@icon.authenticated_s3_url)])
stub_request(:get, @icon.authenticated_s3_url).to_return(body: File.open("spec/fixtures/icon.svg"))
end
it "returns metadata from the icon" do
api_call(:get, @file_path, @file_path_options, {}, {}, expected_status: 200)
json = JSON.parse(response.body)
expect(json["type"]).to eq "image/svg+xml-icon-maker-icons"
expect(json["encodedImage"]).to be_starts_with "data:image/svg+xml;base64,PHN2ZyB3aWR0aD"
end
end
end
describe "#reset_verifier" do
before :once do
@root = Folder.root_folders(@course).first

1
spec/fixtures/icon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 249 KiB

20
spec/fixtures/icon_with_bad_xml.svg vendored Normal file
View File

@ -0,0 +1,20 @@
<svg fill="none" height="164px" viewBox="0 0 122 164" width="122px" xmlns="http://www.w3.org/2000/svg">
<metadata>{&quot;type&quot;:&quot;image/svg+xml-icon-maker-icons&quot;,&quot;alt&quot;:&quot;&quot;,&quot;shape&quot;:&quot;square&quot;,&quot;size&quot;:&quot;small&quot;,&quot;color&quot;:&quot;#FFFFFF&quot;,&quot;outlineColor&quot;:&quot;#65499D&quot;,&quot;outlineSize&quot;:&quot;large&quot;,&quot;text&quot;:&quot;Hello&quot;,&quot;textSize&quot;:&quot;x-large&quot;,&quot;textColor&quot;:&quot;#65499D&quot;,&quot;textBackgroundColor&quot;:&quot;#FFFFFF&quot;,&quot;textPosition&quot;:&quot;bottom-third&quot;,&quot;encodedImage&quot;:&quot;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgwIiBoZWlnaHQ9IjQ4MCIgdmlld0JveD0iMCAwIDQ4MCA0ODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICAgIDxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF81MDdfMzMwKSI+CiAgICAgICAgPHBhdGggZD0iTTI0NS4yMjQgMTE1LjI2NEMyNDQuNTcyIDExNS45NSAyNDMuNzg3IDExNi40OTYgMjQyLjkxNyAxMTYuODdDMjQyLjA0OCAxMTcuMjQzIDI0MS4xMTEgMTE3LjQzNiAyNDAuMTY0IDExNy40MzZDMjM5LjIxOCAxMTcuNDM2IDIzOC4yODEgMTE3LjI0MyAyMzcuNDExIDExNi44N0MyMzYuNTQyIDExNi40OTYgMjM1Ljc1NyAxMTUuOTUgMjM1LjEwNSAxMTUuMjY0QzE1My4yMDggMjkuNTc3MSA1Ni4wMzE5IDExNS4yNjQgODUuMzQ3NiAyMTAuMjQzQzg1Ljk1NTkgMjEwLjA0NyA4Ni41OTEgMjA5Ljk0NyA4Ny4yMzAyIDIwOS45NDZIMTI4LjM1M0wxNDIuNTM4IDEzOS4yNDJDMTQyLjgyNyAxMzcuNzkzIDE0My42MjYgMTM2LjQ5NiAxNDQuNzkgMTM1LjU4NkMxNDUuOTU0IDEzNC42NzUgMTQ3LjQwNiAxMzQuMjEzIDE0OC44ODIgMTM0LjI4MkMxNTAuMzU4IDEzNC4zNTIgMTUxLjc2IDEzNC45NDggMTUyLjgzNCAxMzUuOTYzQzE1My45MDggMTM2Ljk3OCAxNTQuNTgyIDEzOC4zNDUgMTU0LjczNCAxMzkuODE1TDE2NS43MDQgMjQ0LjkwNkwxNzkuOTk1IDE3My44QzE4MC4yODEgMTcyLjQxNCAxODEuMDMzIDE3MS4xNjggMTgyLjEyNSAxNzAuMjY4QzE4My4yMTcgMTY5LjM2OSAxODQuNTg0IDE2OC44NyAxODUuOTk5IDE2OC44NTVIMTg2LjA1NkMxODcuNDYyIDE2OC44NjEgMTg4LjgyNCAxNjkuMzQ0IDE4OS45MiAxNzAuMjI0QzE5MS4wMTYgMTcxLjEwNSAxOTEuNzgxIDE3Mi4zMzIgMTkyLjA4OCAxNzMuNzA0TDIwMi4xNjUgMjE5LjY0NUwyMTAuMTk0IDE5Mi45ODlDMjEwLjU1OCAxOTEuNzc3IDIxMS4yODYgMTkwLjcwNiAyMTIuMjggMTg5LjkyMkMyMTMuMjczIDE4OS4xMzggMjE0LjQ4MyAxODguNjc4IDIxNS43NDcgMTg4LjYwNEMyMTcuMDEgMTg4LjUzIDIxOC4yNjYgMTg4Ljg0NyAyMTkuMzQzIDE4OS41MUMyMjAuNDIxIDE5MC4xNzQgMjIxLjI2OSAxOTEuMTUzIDIyMS43NzEgMTkyLjMxNEwyMjkuNDA0IDIwOS45NDZIMjY5LjEyOUMyNzAuNDk3IDIwNy40OSAyNzIuNjQzIDIwNS41NTggMjc1LjIyOCAyMDQuNDUyQzI3Ny44MTMgMjAzLjM0NyAyODAuNjkyIDIwMy4xMzEgMjgzLjQxMyAyMDMuODM4QzI4Ni4xMzUgMjA0LjU0NSAyODguNTQ0IDIwNi4xMzUgMjkwLjI2NCAyMDguMzU5QzI5MS45ODUgMjEwLjU4MyAyOTIuOTE4IDIxMy4zMTUgMjkyLjkxOCAyMTYuMTI3QzI5Mi45MTggMjE4LjkzOSAyOTEuOTg1IDIyMS42NzEgMjkwLjI2NCAyMjMuODk1QzI4OC41NDQgMjI2LjExOSAyODYuMTM1IDIyNy43MSAyODMuNDEzIDIyOC40MTdDMjgwLjY5MiAyMjkuMTI0IDI3Ny44MTMgMjI4LjkwOCAyNzUuMjI4IDIyNy44MDJDMjcyLjY0MyAyMjYuNjk3IDI3MC40OTcgMjI0Ljc2NCAyNjkuMTI5IDIyMi4zMDhIMjI1LjM2QzIyNC4xNTkgMjIyLjMwNiAyMjIuOTg0IDIyMS45NTQgMjIxLjk4IDIyMS4yOTRDMjIwLjk3NiAyMjAuNjM0IDIyMC4xODcgMjE5LjY5NSAyMTkuNzA5IDIxOC41OTNMMjE3LjE2NiAyMTIuNzI2TDIwNy4xNTIgMjQ1Ljk1OUMyMDYuNzU5IDI0Ny4yNjkgMjA1Ljk0MiAyNDguNDExIDIwNC44MjggMjQ5LjIwN0MyMDMuNzE1IDI1MC4wMDIgMjAyLjM2OSAyNTAuNDA1IDIwMS4wMDIgMjUwLjM1M0MxOTkuNjM1IDI1MC4zIDE5OC4zMjQgMjQ5Ljc5NSAxOTcuMjc1IDI0OC45MTZDMTk2LjIyNiAyNDguMDM4IDE5NS40OTkgMjQ2LjgzNiAxOTUuMjA3IDI0NS40OTlMMTg2LjMxNCAyMDUuMDQ3TDE2OS44MjYgMjg3LjA4NUMxNjkuNTQ5IDI4OC40ODMgMTY4Ljc5NCAyODkuNzQxIDE2Ny42OTEgMjkwLjY0M0MxNjYuNTg3IDI5MS41NDYgMTY1LjIwNSAyOTIuMDM2IDE2My43NzkgMjkyLjAzSDE2My40NzlDMTYyLjAwMiAyOTEuOTY4IDE2MC41OTYgMjkxLjM3NCAxNTkuNTIxIDI5MC4zNTlDMTU4LjQ0NiAyODkuMzQzIDE1Ny43NzMgMjg3Ljk3NCAxNTcuNjI3IDI4Ni41MDJMMTQ2LjY3NyAxODEuNDY0TDEzOS40NjkgMjE3LjMzOUMxMzkuMTgzIDIxOC43MzQgMTM4LjQyNCAyMTkuOTg3IDEzNy4zMjEgMjIwLjg4OEMxMzYuMjE4IDIyMS43ODkgMTM0LjgzOSAyMjIuMjgyIDEzMy40MTUgMjIyLjI4M0g4OS43MzA4Qzk1LjUwOTIgMjM2LjAxNiAxMDQuMDE4IDI0OS43OCAxMTUuNjczIDI2My4xMjFMMjMyLjIzIDM5Ni4zODdDMjMzLjIxMyAzOTcuNTIxIDIzNC40MjkgMzk4LjQzMSAyMzUuNzk1IDM5OS4wNTRDMjM3LjE2MSAzOTkuNjc4IDIzOC42NDUgNDAwIDI0MC4xNDcgNDAwQzI0MS42NDggNDAwIDI0My4xMzIgMzk5LjY3OCAyNDQuNDk4IDM5OS4wNTRDMjQ1Ljg2NCAzOTguNDMxIDI0Ny4wOCAzOTcuNTIxIDI0OC4wNjQgMzk2LjM4N0wzNjQuNjIgMjYzLjEyMUM0NjAuMzY2IDE1My42NjcgMzQxLjc5MyAxNC4zMzY0IDI0NS4yMjQgMTE1LjI2NFoiIGZpbGw9IiMwMDAwMDAiLz4KICAgICAgPC9nPgogICAgICA8ZGVmcz4KICAgICAgPGNsaXBQYXRoIGlkPSJjbGlwMF81MDdfMzMwIj4KICAgICAgICA8cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjMyMCIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgwIDgwKSIvPgogICAgICA8L2NsaXBQYXRoPgogICAgICA8L2RlZnM+CiAgICA8L3N2Zz4KICAgIA==&quot;,&quot;encodedImageType&quot;:&quot;SingleColor&quot;,&quot;encodedImageName&quot;:&quot;Health Icon&quot;,&quot;x&quot;:&quot;50%&quot;,&quot;y&quot;:&quot;50%&quot;,&quot;translateX&quot;:-54,&quot;translateY&quot;:-54,&quot;width&quot;:108,&quot;height&quot;:108,&quot;transform&quot;:&quot;translate(-54,-54)&quot;}
</metadata>
<svg fill="none" height="122px" viewBox="0 0 122 122" width="122px" x="0">
<g fill="#FFFFFF" stroke="#65499D" stroke-width="8">
<clipPath id="clip-path-for-embed">
<rect height="114" width="114" x="4" y="4"/>
</clipPath>
<rect height="114" width="114" x="4" y="4"/>
<g clip-path="url(#clip-path-for-embed)">
<image height="108" href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDgwIiBoZWlnaHQ9IjQ4MCIgdmlld0JveD0iMCAwIDQ4MCA0ODAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgICAgIDxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF81MDdfMzMwKSI+CiAgICAgICAgPHBhdGggZD0iTTI0NS4yMjQgMTE1LjI2NEMyNDQuNTcyIDExNS45NSAyNDMuNzg3IDExNi40OTYgMjQyLjkxNyAxMTYuODdDMjQyLjA0OCAxMTcuMjQzIDI0MS4xMTEgMTE3LjQzNiAyNDAuMTY0IDExNy40MzZDMjM5LjIxOCAxMTcuNDM2IDIzOC4yODEgMTE3LjI0MyAyMzcuNDExIDExNi44N0MyMzYuNTQyIDExNi40OTYgMjM1Ljc1NyAxMTUuOTUgMjM1LjEwNSAxMTUuMjY0QzE1My4yMDggMjkuNTc3MSA1Ni4wMzE5IDExNS4yNjQgODUuMzQ3NiAyMTAuMjQzQzg1Ljk1NTkgMjEwLjA0NyA4Ni41OTEgMjA5Ljk0NyA4Ny4yMzAyIDIwOS45NDZIMTI4LjM1M0wxNDIuNTM4IDEzOS4yNDJDMTQyLjgyNyAxMzcuNzkzIDE0My42MjYgMTM2LjQ5NiAxNDQuNzkgMTM1LjU4NkMxNDUuOTU0IDEzNC42NzUgMTQ3LjQwNiAxMzQuMjEzIDE0OC44ODIgMTM0LjI4MkMxNTAuMzU4IDEzNC4zNTIgMTUxLjc2IDEzNC45NDggMTUyLjgzNCAxMzUuOTYzQzE1My45MDggMTM2Ljk3OCAxNTQuNTgyIDEzOC4zNDUgMTU0LjczNCAxMzkuODE1TDE2NS43MDQgMjQ0LjkwNkwxNzkuOTk1IDE3My44QzE4MC4yODEgMTcyLjQxNCAxODEuMDMzIDE3MS4xNjggMTgyLjEyNSAxNzAuMjY4QzE4My4yMTcgMTY5LjM2OSAxODQuNTg0IDE2OC44NyAxODUuOTk5IDE2OC44NTVIMTg2LjA1NkMxODcuNDYyIDE2OC44NjEgMTg4LjgyNCAxNjkuMzQ0IDE4OS45MiAxNzAuMjI0QzE5MS4wMTYgMTcxLjEwNSAxOTEuNzgxIDE3Mi4zMzIgMTkyLjA4OCAxNzMuNzA0TDIwMi4xNjUgMjE5LjY0NUwyMTAuMTk0IDE5Mi45ODlDMjEwLjU1OCAxOTEuNzc3IDIxMS4yODYgMTkwLjcwNiAyMTIuMjggMTg5LjkyMkMyMTMuMjczIDE4OS4xMzggMjE0LjQ4MyAxODguNjc4IDIxNS43NDcgMTg4LjYwNEMyMTcuMDEgMTg4LjUzIDIxOC4yNjYgMTg4Ljg0NyAyMTkuMzQzIDE4OS41MUMyMjAuNDIxIDE5MC4xNzQgMjIxLjI2OSAxOTEuMTUzIDIyMS43NzEgMTkyLjMxNEwyMjkuNDA0IDIwOS45NDZIMjY5LjEyOUMyNzAuNDk3IDIwNy40OSAyNzIuNjQzIDIwNS41NTggMjc1LjIyOCAyMDQuNDUyQzI3Ny44MTMgMjAzLjM0NyAyODAuNjkyIDIwMy4xMzEgMjgzLjQxMyAyMDMuODM4QzI4Ni4xMzUgMjA0LjU0NSAyODguNTQ0IDIwNi4xMzUgMjkwLjI2NCAyMDguMzU5QzI5MS45ODUgMjEwLjU4MyAyOTIuOTE4IDIxMy4zMTUgMjkyLjkxOCAyMTYuMTI3QzI5Mi45MTggMjE4LjkzOSAyOTEuOTg1IDIyMS42NzEgMjkwLjI2NCAyMjMuODk1QzI4OC41NDQgMjI2LjExOSAyODYuMTM1IDIyNy43MSAyODMuNDEzIDIyOC40MTdDMjgwLjY5MiAyMjkuMTI0IDI3Ny44MTMgMjI4LjkwOCAyNzUuMjI4IDIyNy44MDJDMjcyLjY0MyAyMjYuNjk3IDI3MC40OTcgMjI0Ljc2NCAyNjkuMTI5IDIyMi4zMDhIMjI1LjM2QzIyNC4xNTkgMjIyLjMwNiAyMjIuOTg0IDIyMS45NTQgMjIxLjk4IDIyMS4yOTRDMjIwLjk3NiAyMjAuNjM0IDIyMC4xODcgMjE5LjY5NSAyMTkuNzA5IDIxOC41OTNMMjE3LjE2NiAyMTIuNzI2TDIwNy4xNTIgMjQ1Ljk1OUMyMDYuNzU5IDI0Ny4yNjkgMjA1Ljk0MiAyNDguNDExIDIwNC44MjggMjQ5LjIwN0MyMDMuNzE1IDI1MC4wMDIgMjAyLjM2OSAyNTAuNDA1IDIwMS4wMDIgMjUwLjM1M0MxOTkuNjM1IDI1MC4zIDE5OC4zMjQgMjQ5Ljc5NSAxOTcuMjc1IDI0OC45MTZDMTk2LjIyNiAyNDguMDM4IDE5NS40OTkgMjQ2LjgzNiAxOTUuMjA3IDI0NS40OTlMMTg2LjMxNCAyMDUuMDQ3TDE2OS44MjYgMjg3LjA4NUMxNjkuNTQ5IDI4OC40ODMgMTY4Ljc5NCAyODkuNzQxIDE2Ny42OTEgMjkwLjY0M0MxNjYuNTg3IDI5MS41NDYgMTY1LjIwNSAyOTIuMDM2IDE2My43NzkgMjkyLjAzSDE2My40NzlDMTYyLjAwMiAyOTEuOTY4IDE2MC41OTYgMjkxLjM3NCAxNTkuNTIxIDI5MC4zNTlDMTU4LjQ0NiAyODkuMzQzIDE1Ny43NzMgMjg3Ljk3NCAxNTcuNjI3IDI4Ni41MDJMMTQ2LjY3NyAxODEuNDY0TDEzOS40NjkgMjE3LjMzOUMxMzkuMTgzIDIxOC43MzQgMTM4LjQyNCAyMTkuOTg3IDEzNy4zMjEgMjIwLjg4OEMxMzYuMjE4IDIyMS43ODkgMTM0LjgzOSAyMjIuMjgyIDEzMy40MTUgMjIyLjI4M0g4OS43MzA4Qzk1LjUwOTIgMjM2LjAxNiAxMDQuMDE4IDI0OS43OCAxMTUuNjczIDI2My4xMjFMMjMyLjIzIDM5Ni4zODdDMjMzLjIxMyAzOTcuNTIxIDIzNC40MjkgMzk4LjQzMSAyMzUuNzk1IDM5OS4wNTRDMjM3LjE2MSAzOTkuNjc4IDIzOC42NDUgNDAwIDI0MC4xNDcgNDAwQzI0MS42NDggNDAwIDI0My4xMzIgMzk5LjY3OCAyNDQuNDk4IDM5OS4wNTRDMjQ1Ljg2NCAzOTguNDMxIDI0Ny4wOCAzOTcuNTIxIDI0OC4wNjQgMzk2LjM4N0wzNjQuNjIgMjYzLjEyMUM0NjAuMzY2IDE1My42NjcgMzQxLjc5MyAxNC4zMzY0IDI0NS4yMjQgMTE1LjI2NFoiIGZpbGw9IiMwMDAwMDAiLz4KICAgICAgPC9nPgogICAgICA8ZGVmcz4KICAgICAgPGNsaXBQYXRoIGlkPSJjbGlwMF81MDdfMzMwIj4KICAgICAgICA8cmVjdCB3aWR0aD0iMzIwIiBoZWlnaHQ9IjMyMCIgZmlsbD0id2hpdGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDgwIDgwKSIvPgogICAgICA8L2NsaXBQYXRoPgogICAgICA8L2RlZnM+CiAgICA8L3N2Zz4KICAgIA==" transform="translate(-54,-54)" width="108" x="50%" y="50%"/>
</g>
</g>
</svg>
<path d="M28,102 h66 a4,4 0 0 1 4,4 v30 a4,4 0 0 1 -4,4 h-66 a4,4 0 0 1 -4,-4 v-30 a4,4 0 0 1 4,-4 z" fill="#FFFFFF"/>
<text fill="#65499D" font-family="Lato Extended" font-size="28" font-weight="bold" x="28" y="132">
<tspan dy="0" x="28">Hello</tspan>
</text>
<style type="text/css">@font-face {font-family: &quot;Lato Extended&quot;;font-weight: bold;src: url(data:font/woff2;base64,d09GMgABAAAAAtJQABEAAAAJKxgAAtHnAAID1wAAAAAAAAAAAAAAAAAAAAAAAAAAG439fhzQZgZgAKskCIFmCZcXEQgKjf1Ei8c+ATYCJAPePBOEgAIL3kAABCAFrhkHgdRvDIE5W6Nf+Hr/v3dq73oytiXNJOOJZCfSOlIVlcHuOSEqAHqXXcBFcLrsFIiW6Bvxe/kLwdUQ/5sqALtqmjav/26XPRAyZHbk22RBOpmUbg9l09dpXw8JOySg2nW7fykX/m2MvQ845sDITtTOsmt4Wy8qREDZ/////////////////////////////////////////////////////////////////////////////////////+WH/L39zMlNJ7l6RMlFoQWr9ZdIEmQMWXKFeb7vBRSS50V88l3mRm0snvBdnweuG3OFkhS6NnCZpITSKJM1lBOSIO/6ge8XikGQKpVjlVVBEAuqa2p98mN19dTgeZRv5DZFxYI0NbvlcgsTaXWtpRbWFrXMkmSNZ8kYsb4hV4yIkCskFPMjriusnToikURnhrqifnWMTar10ilvcjoMwBSaanhDN1nLezyylmKxZNF29frURxFy+yMhaGADCRrkQ3A4ASWBREWQKIWTKAZS06ZngfAZFEbgTEFiDDIkSJSCaGP1LBbMnlNsbaPy3HlgfgpH00hoBFkSJKPNyFBnaqyq2ArGO2mCLViIEmwRF8o00OL5tITGaSkPqIOWcc8ylxfZcm3Lp0ZpYLhMMiRMSsNuhxQWKDcLXKHaUDMXvCKtlMnSyrQKGgYuaxEKlOMqjWhVbKmee6u1r15Ca1QZ6pcylaeSVBe8dvIo73u0psvc8Wpaq9Ejz/PYiJdx+VLXpmJUkLXZvHUkbVhNkoxwU4XWNXBmr0FmvR6UzoeU4j6lKU3rp9gGGw4L32iMTQgzcVTOGmQNFDJISJCwWK/IxmCTKrQp2syBCBKHDI8OQXfO6sBasPkWw1u6me5ydr2gJqCtQnLJ0taUoYzrZoqxAUn6WYs8to3CtrQtXwbbdaqmwnZFWrQ9FIVOJEEOjc4xyFXagTJk1XZEgYMGiVr9TnmcYtGpcKQehSTICBSKICGDhKrXJJ/tzJK7UOcwmydgKA5jxqAllEBCAqxhuy7ilWwa7bY52L0kYBjuMQdJVQoJCRIHdlmlvo66YmqNjhVQAwlqVlxrT75XiygJ83RJkrVMFPYAe4+sA5qhu4+gRh3aWtA4srhVcBUSbMAWZAwo8432pRntbcx4aK/lZ6P9KIGEhAaZZy0UEjavf3+X5aNM+AEHztV9Ec09qIrPDIV39EKBXQdnYVGQSyNIqKuTIuwQFNc6CKCkTCJXQ57CYnGystK3JGFABs/uAkaAB5vTBuVJgGUCphiWjFEtWZYa3ybeDwO8/aGarpUBo4cdrjg1i1IqWTSOXZ4M0RGb0GzmTasakeE1KaHQiSRIAVEwdbyRWTNCjUfSUY1KgWMHoVBxH2wdqNbsaPVjKuBjlccq4iH9XQf21mEnJdF/J/Ut9N/JoW5HjzveiQG1slPrK51A+1Ba94060WoqG/coNeGogyeedPJhFcc+uKleg2lKzeqn6D+/4o2qbI1OZYc6swJMOXLa6ccpnqHtmaOnj4/vSGPVFnaD1rPIRUICjCEDVjemcvrZzPdybutSWnrwORT1PaH0BglyXRaQhfGzDZJzDTICRaAkfIXjCofTXN7RseyMNC1UPo/i6HxpQxN4XYrSnlScvCHbXiW5Tp0jcVRNgpqxoQuOBbtr3XThRXDfizeGm4I4LItqKDBBgi4hj+aLJ/tTB2/lslphMq+Lwa0EbXepQZZCJGSQ7DmFlvUbflmSLr+CX8kv24zFVYskSOhIdChei666WgQdyK+h5CxwLRO2AxCys+Eih2ePzAPrGZoOrjMGiuuRZaFYAS3GiJBHrgHGZGjI2P3NCF1ryQLXCGuQluP4oRRcb5llwkOf8pb6hc0moblxH9itDEsIu+GyKmNuZAlb9IPJMYr4ctPN3bcwzzt2eWphrhuPx29lnkdB7jbmugs8ukRpGvf2o5Xs2Ud28GAQzhFVIR8JCZKTB2DYgvp43/bHzmCdsEejK9iNDezKk6pWXat0u0q0pB5HRuGO1dWmanrnXZvebcDRk/12uMpkR5KqWZWJRjBN6Sydd9bWLoMzFALdrEKDvhvCM+j89Jl8M7Y361bIYFG9uk7hnntJ4O3abnjhfQLu1LeV7TqueTtMUW571j0131ICx6ve4pdUi0qX0v0P4BwYhPMMNCLcd9aorIGX39LRBrSPyoMPycOPGLULdZ1zEIzScZdcZ92dD0jiEfwgFJimK5HQxLkV/6P6n7+q/

After

Width:  |  Height:  |  Size: 11 KiB