summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FS/FS/Schema.pm14
-rw-r--r--FS/FS/template_image.pm222
-rw-r--r--httemplate/browse/msg_template.html3
-rw-r--r--httemplate/browse/template_image.html68
-rw-r--r--httemplate/edit/msg_template.html11
-rw-r--r--httemplate/elements/form-file_upload.html1
-rw-r--r--httemplate/elements/images/ui-icons_ef8c08_256x240.pngbin0 -> 4369 bytes
-rw-r--r--httemplate/elements/template_image-dialog.html279
-rw-r--r--httemplate/misc/email-customers.html2
-rw-r--r--httemplate/misc/process/template_image-delete.cgi28
-rw-r--r--httemplate/misc/process/template_image-upload.cgi26
-rw-r--r--httemplate/misc/xmlhttp-template_image.cgi48
12 files changed, 698 insertions, 4 deletions
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 2ff92eb2c..a799ceebe 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -204,6 +204,7 @@ sub dbdef_dist {
&& ( ! /^queue(_arg|_depend|_stat)?$/ || ! $opt->{'queue-no_history'} )
&& ! $tables_hashref_torrus->{$_}
&& ! /^cacti_page$/
+ && ! /^template_image$/
}
$dbdef->tables
) {
@@ -6350,6 +6351,19 @@ sub tables_hashref {
],
},
+ 'template_image' => {
+ 'columns' => [
+ 'imgnum', 'serial', '', '', '', '',
+ 'name', 'varchar', '', $char_d, '', '',
+ 'agentnum', 'int', 'NULL', '', '', '',
+ 'mime_type', 'varchar', '', $char_d, '', '',
+ 'base64', 'text', '', '', '', '',
+ ],
+ 'primary_key' => 'imgnum',
+ 'unique' => [ ],
+ 'index' => [ ['name'], ['agentnum'] ],
+ },
+
'cust_msg' => {
'columns' => [
'custmsgnum', 'serial', '', '', '', '',
diff --git a/FS/FS/template_image.pm b/FS/FS/template_image.pm
new file mode 100644
index 000000000..e7f4baba5
--- /dev/null
+++ b/FS/FS/template_image.pm
@@ -0,0 +1,222 @@
+package FS::template_image;
+use base qw( FS::Agent_Mixin FS::Record );
+
+use strict;
+use FS::Record qw( qsearchs );
+use File::Slurp qw( slurp );
+use MIME::Base64 qw( encode_base64 );
+
+my %ext_to_type = (
+ 'jpeg' => 'image/jpeg',
+ 'jpg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+);
+
+=head1 NAME
+
+FS::template_image - Object methods for template_image records
+
+=head1 SYNOPSIS
+
+ use FS::template_image;
+
+ $record = new FS::template_image {
+ 'name' => 'logo',
+ 'agentnum' => $agentnum,
+ 'base64' => encode_base64($rawdata),
+ 'mime_type' => 'image/jpg',
+ };
+
+ $error = $record->insert;
+
+ $error = $new_record->replace($old_record);
+
+ $error = $record->delete;
+
+ $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::template_image object represents an uploaded image for insertion into templates.
+FS::template_image inherits from FS::Record. The following fields are currently supported:
+
+=over 4
+
+=item imgnum - primary key
+
+=item name - unique name, for selecting/editing images
+
+=item agentnum - image agent
+
+=item mime-type - image mime-type
+
+=item base64 - base64-encoded raw contents of image file
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new object. To add the object to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to. You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'template_image'; }
+
+=item insert
+
+Adds this record to the database. If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database. If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid example. If there is
+an error, returns the error, otherwise returns false. Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+ my $self = shift;
+
+ my $error =
+ $self->ut_numbern('imgnum','agentnum')
+ || $self->ut_text('name','mime-type')
+ || $self->ut_anything('base64')
+ ;
+ return $error if $error;
+
+ $self->SUPER::check;
+}
+
+=item src
+
+Returns a data url for this image, incorporating mime_type & base64
+
+=cut
+
+sub src {
+ my $self = shift;
+ 'data:'
+ . $self->mime_type
+ . ';base64,'
+ . $self->base64;
+}
+
+=item html
+
+Returns html for a basic img tag for this image (no attributes)
+
+=cut
+
+sub html {
+ my $self = shift;
+ '<IMG SRC="'
+ . $self->src
+ . '">';
+}
+
+=item process_image_delete
+
+Process for deleting an image. Run as a job using L<FS::queue>.
+
+=cut
+
+sub process_image_delete {
+ my $job = shift;
+ my $param = shift;
+ my $template_image = qsearchs('template_image',{ 'imgnum' => $param->{'imgnum'} })
+ or die "Could not load template_image";
+ my $error = $template_image->delete;
+ die $error if $error;
+ '';
+}
+
+=item process_image_upload
+
+Process for uploading an image. Run as a job using L<FS::queue>.
+
+=cut
+
+sub process_image_upload {
+ my $job = shift;
+ my $param = shift;
+
+ my $files = $param->{'uploaded_files'}
+ or die "No files provided.\n";
+
+ my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
+
+ my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
+ my $file = $dir. $files{'file'};
+
+ my $type;
+ if ( $file =~ /\.(\w+)$/i ) {
+ my $ext = lc($1);
+ die "Unrecognized file extension $ext"
+ unless $ext_to_type{$ext};
+ $type = $ext_to_type{$ext};
+ } else {
+ die "Cannot upload image file without extension"
+ }
+
+ my $template_image = new FS::template_image {
+ 'name' => $param->{'name'},
+ 'mime_type' => $type,
+ 'agentnum' => $param->{'agentnum'},
+ 'base64' => encode_base64( slurp($file, binmode => ':raw'), '' ),
+ };
+ my $error = $template_image->insert();
+ die $error if $error;
+ unlink $file;
+ '';
+
+}
+
+=back
+
+=head1 BUGS
+
+Will be described here once found.
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/httemplate/browse/msg_template.html b/httemplate/browse/msg_template.html
index ef0b2dafd..1646bc169 100644
--- a/httemplate/browse/msg_template.html
+++ b/httemplate/browse/msg_template.html
@@ -28,6 +28,7 @@ my @menubar = ();
if ( $curuser->access_right(['Edit templates', 'Edit global templates']) ) {
push @menubar, 'Add a new template' => $p.'edit/msg_template.html';
}
+push @menubar, 'View template images' => $p.'browse/template_image.html';
my $link = [ "${p}edit/msg_template.html?msgnum=", 'msgnum' ];
@@ -52,7 +53,7 @@ my $disable_link = sub {
action => $p.'misc/disable-msg_template.cgi?msgnum=' .
$template->msgnum .
($template->disabled ? ';enable=1' : ''),
- actionlabel => 'Disable lemplate',
+ actionlabel => 'Disable template',
);
};
diff --git a/httemplate/browse/template_image.html b/httemplate/browse/template_image.html
new file mode 100644
index 000000000..eb4325f15
--- /dev/null
+++ b/httemplate/browse/template_image.html
@@ -0,0 +1,68 @@
+<% include('/elements/init_overlib.html') %>
+
+<% include( 'elements/browse.html',
+ 'title' => 'Template images',
+ 'name_singular' => 'image',
+ 'menubar' => \@menubar,
+ 'query' => { 'table' => 'template_image', },
+ 'count_query' => 'SELECT COUNT(*) FROM template_image',
+ 'agent_virt' => 1,
+ 'agent_null_right' => ['View global templates','Edit global templates'],
+ 'agent_pos' => 1,
+ 'header' => [ 'Name', '', '' ],
+ 'fields' => [ 'name', $tag, $delete_text ],
+ 'links' => [ '', '', '' ],
+ 'cell_style' => [ '', '', '' ],
+ )
+%>
+
+<% include('/elements/template_image-dialog.html',
+ 'url' => $p.'browse/template_image.html'
+ ) %>
+
+<%init>
+use FS::template_image;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+ unless $curuser->access_right([ 'View templates', 'View global templates',
+ 'Edit templates', 'Edit global templates', ]);
+
+my $canedit = $curuser->access_right(['Edit templates', 'Edit global templates']);
+
+my @menubar = ();
+if ($canedit) {
+ push @menubar, 'Upload a new image' => 'javascript:insertImageDialog(\'upload\')';
+}
+push @menubar, ( 'View message templates' => $p.'browse/msg_template.html' );
+
+my $tag = sub { qq!<A HREF="javascript:insertImageDialog(! . $_[0]->imgnum . qq!)">view</A>! };
+
+my $delete_text = $canedit ? sub {
+ my $image = shift;
+ my $imgnum = $image->imgnum;
+ unless ($image->agentnum) {
+ unless ($FS::CurrentUser::CurrentUser->access_right('Edit global templates')) {
+ return '';
+ }
+ }
+ my $out = <<EOF;
+<FORM name="delete_template_image_$imgnum">
+<INPUT TYPE="hidden" name="imgnum" value="$imgnum">
+</FORM>
+EOF
+ $out .= include('/elements/progress-init.html',
+ "delete_template_image_$imgnum",
+ [ 'imgnum' ],
+ $p.'misc/process/template_image-delete.cgi',
+ $p.'browse/template_image.html',
+ "imgnum$imgnum",
+ );
+ my $onclick = 'if ( confirm(\'';
+ $onclick .= emt('Are you sure you want to delete template image ') . $imgnum;
+ $onclick .= '\') ) { imgnum' . $imgnum . 'process() }';
+ return $out . '<A HREF="javascript:void(0)" ONCLICK="' . $onclick . '">delete</A>';
+} : '';
+
+</%init>
diff --git a/httemplate/edit/msg_template.html b/httemplate/edit/msg_template.html
index 7f3824127..df72c5b66 100644
--- a/httemplate/edit/msg_template.html
+++ b/httemplate/edit/msg_template.html
@@ -27,6 +27,7 @@
'no_submit' => $no_submit,
&>
<%init>
+use FS::template_image;
my $curuser = $FS::CurrentUser::CurrentUser;
@@ -345,10 +346,16 @@ function areyousure(url, message) {
<TD valign="top"><FORM name="dummy">
Substitutions: '
. $widget->html .
-'<BR>Click links to insert.
-<BR>Enclose substitutions and other Perl expressions in braces:
+'<P>Click above links to insert substitution code.</P>
+<P>
+Enclose substitutions and other Perl expressions in braces:
<BR>{ $name } = ExampleCo (Smith, John)
<BR>{ time2str("%D", time) } = '.time2str("%D", time).'
+</P>';
+$sidebar .= include('/elements/template_image-dialog.html',
+ 'callback' => 'insertHtml'
+ );
+$sidebar .= '<P><A HREF="javascript:insertImageDialog()">Insert Uploaded Image</A></P>
</FONT></TD>
';
diff --git a/httemplate/elements/form-file_upload.html b/httemplate/elements/form-file_upload.html
index 45b6c97f2..3542a5a8e 100644
--- a/httemplate/elements/form-file_upload.html
+++ b/httemplate/elements/form-file_upload.html
@@ -69,6 +69,7 @@ Example:
<div style="display:none:" id="uploadError"></div>
<FORM NAME = "<% $opt{name} %>"
+ ID = "<% $opt{id} %>"
ACTION = "<% $fsurl %>misc/file-upload.html"
METHOD = "POST"
ENCTYPE = "multipart/form-data"
diff --git a/httemplate/elements/images/ui-icons_ef8c08_256x240.png b/httemplate/elements/images/ui-icons_ef8c08_256x240.png
new file mode 100644
index 000000000..85e63e9f6
--- /dev/null
+++ b/httemplate/elements/images/ui-icons_ef8c08_256x240.png
Binary files differ
diff --git a/httemplate/elements/template_image-dialog.html b/httemplate/elements/template_image-dialog.html
new file mode 100644
index 000000000..5691d52b5
--- /dev/null
+++ b/httemplate/elements/template_image-dialog.html
@@ -0,0 +1,279 @@
+<%doc>
+
+Creates a jquery dialog box that opens when javascript function insertImageDialog
+is called, allows user to select an image and specify attributes for it, then passes
+img tag with base64 encoded data url to a callback javascript function.
+
+Accepts the following options:
+
+callback - pass the html for the selected img to this javascript function;
+if omitted, will only include fields for viewing/uploading image
+
+url - to redirect to after upload, otherwise just refreshes dialog window
+
+</%doc>
+
+<% include('/elements/xmlhttp.html',
+ 'url' => $p.'misc/xmlhttp-template_image.cgi',
+ 'subs' => [ 'get_template_image' ],
+ ) %>
+
+<DIV ID="insert_image_dialog" title="Template Images">
+
+<TABLE BORDER="0" STYLE="width: 100%"><TR><TD>
+
+<FORM ID="insert_image_form">
+
+<% &ntable("#cccccc", 2) %>
+
+ <TR>
+ <TH>Image</TH>
+ <TD>
+ <SELECT ID="insert_image_imgnum" ONCHANGE="insertImageDialog($('#insert_image_imgnum').val())">
+ <OPTION VALUE="">(select an image)</OPTION>
+ </SELECT>
+ </TD>
+ </TR>
+% if ($opt{'callback'}) {
+ <TR>
+ <TH>Width</TH>
+ <TD><INPUT TYPE="text" SIZE="5" ID="insert_image_width" ONCHANGE="previewInsertImage()"></TD>
+ </TR>
+ <TR>
+ <TH>Height</TH>
+ <TD><INPUT TYPE="text" SIZE="5" ID="insert_image_height" ONCHANGE="previewInsertImage()"></TD>
+ </TR>
+ <TR>
+ <TH>Align</TH>
+ <TD>
+ <SELECT ID="insert_image_float" ONCHANGE="previewInsertImage()">
+ <OPTION VALUE="none">inline</OPTION>
+ <OPTION VALUE="left">left</OPTION>
+ <OPTION VALUE="right">right</OPTION>
+ </SELECT>
+ </TD>
+ </TR>
+ <TR>
+ <TH>Alt Text</TH>
+ <TD><INPUT TYPE="text" SIZE="20" ID="insert_image_alt" ONCHANGE="previewInsertImage()"></TD>
+ </TR>
+ <TR>
+ <TD COLSPAN="2" ALIGN="center" STYLE="padding-top:6px">
+ <INPUT TYPE="button" ID="insert_image_button" VALUE="Insert Image" ONCLICK="insertImage()">
+ </TD>
+ </TR>
+% } # if $opt{'callback'}
+
+</TABLE>
+
+</FORM>
+
+% if ($canedit) {
+
+<P><B><% emt('Upload New Image') %></B></P>
+
+<% include('/elements/form-file_upload.html',
+ 'name' => 'TemplateImageUploadForm',
+ 'id' => 'TemplateImageUploadForm',
+ 'action' => $p.'misc/process/template_image-upload.cgi',
+ 'num_files' => 1,
+ 'fields' => [ 'name', 'agentnum' ],
+ 'url' => $opt{'url'} || 'javascript:refreshImageList(1)',
+ )
+ %>
+
+ <% &ntable("#cccccc", 2) %>
+
+ <% include( '/elements/tr-input-text.html',
+ 'field' => 'name',
+ 'label' => 'Name',
+ 'required' => 1,
+ 'id' => 'upload_form_name',
+ )
+ %>
+
+ <% include( '/elements/tr-select-agent.html',
+ 'label' => "<B>Agent</B>",
+ 'empty_label' => 'Select agent',
+ 'agent_virt' => 1,
+ 'agent_null_right' => 'Edit global templates',
+ )
+ %>
+
+ <% include( '/elements/tr-file-upload.html',
+ 'field' => 'file',
+ 'label' => 'File',
+ )
+ %>
+
+ <TR>
+ <TD COLSPAN="2" ALIGN="center" STYLE="padding-top:6px">
+ <INPUT TYPE = "submit"
+ NAME = "submitButton"
+ ID = "submitButton"
+ VALUE = "Upload image"
+ >
+ </TD>
+ </TR>
+
+</TABLE>
+
+</FORM>
+
+% } #if canedit
+
+</TD><TD width="100%">
+
+<DIV ID="insert_image_preview_box">
+ <P><B><% emt('Image Preview') %></B></P>
+ <SPAN ID="insert_image_loading"><B>(<% emt('Loading image...') %>)</B></SPAN>
+ <IMG SRC="" ID="insert_image_preview">
+</DIV>
+
+</TD></TR></TABLE>
+</DIV>
+
+<SCRIPT>
+
+// initialize & close dialog window, initialize imgobj cache && image list
+$( '#insert_image_dialog' ).dialog({
+ width: 800,
+ height: 550,
+ resizable: true,
+ autoOpen: false,
+});
+var imgobj = new Object;
+refreshImageList(0);
+
+// this is the main func to invoke from links outside this file.
+// opens dialog if needed
+// updates dialog with passed imgnum
+// caches image info through an xmlhttp request if needed
+// pass 'upload' as imgnum for upload-only view
+function insertImageDialog (imgnum) {
+ if (imgnum == 'upload') {
+ $('#insert_image_form').hide();
+ $('#insert_image_preview_box').hide();
+ imgnum = undefined;
+ } else {
+ $('#insert_image_form').show();
+ $('#insert_image_preview_box').show();
+ }
+ if (imgnum && !imgobj[imgnum]) {
+ clearInsertImageDialog();
+ $('#insert_image_loading').show();
+ $('#insert_image_imgnum').val(imgnum);
+ get_template_image('imgnum',imgnum,
+ function (result) {
+ var images = JSON.parse(result) || [];
+ for (i = 0; i < images.length; i++) {
+ imgobj[images[i].imgnum] = images[i];
+ }
+ updateInsertImageDialog();
+ }
+ );
+ } else if (imgnum) {
+ $('#insert_image_imgnum').val(imgnum);
+ updateInsertImageDialog();
+ } else {
+ clearInsertImageDialog();
+ }
+ if (!$( '#insert_image_dialog' ).dialog( 'isOpen' )) {
+ $( '#insert_image_dialog' ).dialog( 'open' );
+ }
+}
+
+// sets dialog values to a default "Loading..." state, including imgnum
+function clearInsertImageDialog () {
+ $('#insert_image_imgnum').val('');
+ $('#insert_image_preview').attr('src','');
+ $('#insert_image_loading').hide();
+}
+
+// updates preview src from cache based on imgnum from form
+// then calls previewInsertImage
+function updateInsertImageDialog () {
+ var imgnum = $('#insert_image_imgnum').val();
+ $('#insert_image_loading').hide();
+ $('#insert_image_preview').attr('src',imgobj[imgnum].src);
+ previewInsertImage();
+}
+
+// updates preview width/height/alt/float based on current form values
+function previewInsertImage () {
+ $('#insert_image_preview').css('width',$('#insert_image_width').val());
+ $('#insert_image_preview').css('height',$('#insert_image_height').val());
+ $('#insert_image_preview').css('float',$('#insert_image_float').val());
+ $('#insert_image_preview').attr('alt',$('#insert_image_alt').val());
+}
+
+// constructs html based on the form contents,
+// passes it to callback & closes dialog
+function insertImage() {
+ var imgnum = $('#insert_image_imgnum').val();
+ if (!(imgnum && imgobj[imgnum])) {
+ return '';
+ }
+ var width = $('#insert_image_width').val() || '';
+ var height = $('#insert_image_height').val() || '';
+ var alt = $('#insert_image_alt').val() || '';
+ var float = $('#insert_image_float').val();
+ var imgtag = '<IMG SRC="' + imgobj[imgnum].src + '"';
+ if (width) {
+ imgtag += ' WIDTH="' + width + '"';
+ }
+ if (height) {
+ imgtag += ' HEIGHT="' + height + '"';
+ }
+ if (alt) {
+ imgtag += ' ALT="' + alt + '"';
+ }
+ if (float) {
+ imgtag += ' STYLE="float: ' + float + '"';
+ }
+ imgtag += '>';
+ <% $opt{'callback'} %>(imgtag);
+ $( '#insert_image_dialog' ).dialog( 'close' );
+}
+
+// uses xmlhttp request to initialize image list & refresh it after uploads
+function refreshImageList (fromupload) {
+ get_template_image('no_src','1',
+ function (result) {
+ if (fromupload) {
+ $("#TemplateImageUploadForm")[0].reset();
+ }
+ var images = JSON.parse(result) || [];
+ var latest;
+ for (i = 0; i < images.length; i++) {
+ if ( $("#insert_image_imgnum option[value='" + images[i].imgnum + "']").length == 0 ) {
+ $("#insert_image_imgnum").append('<OPTION VALUE="'+images[i].imgnum+'">'+images[i].name+'</OPTION>');
+ latest = images[i].imgnum;
+ }
+ }
+ if (fromupload) {
+ location.hash = "insert_image_dialog";
+ if (latest) {
+ // small risk of a race condition with other newly-uploaded images,
+ // but does no real damage (our image still shows up in the list)
+ insertImageDialog(latest);
+ }
+ }
+ }
+ );
+}
+
+</SCRIPT>
+
+<%init>
+my %opt = @_;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+ unless $curuser->access_right([ 'View templates', 'View global templates',
+ 'Edit templates', 'Edit global templates', ]);
+
+my $canedit = $curuser->access_right([ 'Edit templates', 'Edit global templates' ]);
+</%init>
+
diff --git a/httemplate/misc/email-customers.html b/httemplate/misc/email-customers.html
index 47e6a5b48..8ac44afc1 100644
--- a/httemplate/misc/email-customers.html
+++ b/httemplate/misc/email-customers.html
@@ -36,7 +36,7 @@ should be used to set msgnum or from/subject/html_body cgi params
% }
-<FORM NAME="OneTrueForm" ACTION="<% $form_action %>" METHOD="GET">
+<FORM NAME="OneTrueForm" ACTION="<% $form_action %>" METHOD="POST">
<INPUT TYPE="hidden" NAME="table" VALUE="<% $table %>">
%# Mixing search params with from address, subject, etc. required special-case
%# handling of those, risked name conflicts, and caused massive problems with
diff --git a/httemplate/misc/process/template_image-delete.cgi b/httemplate/misc/process/template_image-delete.cgi
new file mode 100644
index 000000000..58c3f2c68
--- /dev/null
+++ b/httemplate/misc/process/template_image-delete.cgi
@@ -0,0 +1,28 @@
+<% $server->process %>
+
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+# make sure user can generally edit
+die "access denied"
+ unless $curuser->access_right([ 'Edit templates', 'Edit global templates' ]);
+
+# make sure user can edit this particular image
+my %arg = $cgi->param('arg');
+my $imgnum = $arg{'imgnum'};
+die "bad imgnum" unless $imgnum =~ /^\d+$/;
+die "access denied" unless qsearchs({
+ 'table' => 'template_image',
+ 'select' => 'imgnum',
+ 'hashref' => { 'imgnum' => $imgnum },
+ 'extra_sql' => ' AND ' .
+ $curuser->agentnums_sql(
+ 'null_right' => ['Edit global templates']
+ ),
+ });
+
+my $server =
+ new FS::UI::Web::JSRPC 'FS::template_image::process_image_delete', $cgi;
+
+</%init>
diff --git a/httemplate/misc/process/template_image-upload.cgi b/httemplate/misc/process/template_image-upload.cgi
new file mode 100644
index 000000000..c3c905981
--- /dev/null
+++ b/httemplate/misc/process/template_image-upload.cgi
@@ -0,0 +1,26 @@
+<% $server->process %>
+
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+ unless $curuser->access_right([ 'Edit templates', 'Edit global templates' ]);
+
+my %arg = $cgi->param('arg');
+my $agentnum = $arg{'agentnum'};
+
+if (!$agentnum) {
+ die "access denied"
+ unless $curuser->access_right([ 'Edit global templates' ]);
+} else {
+ die "bad agentnum"
+ unless $agentnum =~ /^\d+$/;
+ die "access denied"
+ unless $curuser->agentnum($agentnum);
+}
+
+my $server =
+ new FS::UI::Web::JSRPC 'FS::template_image::process_image_upload', $cgi;
+
+</%init>
diff --git a/httemplate/misc/xmlhttp-template_image.cgi b/httemplate/misc/xmlhttp-template_image.cgi
new file mode 100644
index 000000000..a8c50edf0
--- /dev/null
+++ b/httemplate/misc/xmlhttp-template_image.cgi
@@ -0,0 +1,48 @@
+<%doc>
+Returns JSON encoded array of objects with details about FS::template_image
+objects. Attributes in each returned object are imgnum, name, and src.
+
+Accepts the following options:
+
+imgnum - only return object for this imgnum
+
+no_src - do not include the src field
+
+</%doc>
+<% encode_json(\@result) %>\
+<%init>
+use FS::template_image;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+ unless $curuser->access_right([ 'View templates', 'View global templates',
+ 'Edit templates', 'Edit global templates', ]);
+
+my %arg = $cgi->param('arg');
+
+my $search = {
+ 'table' => 'template_image',
+ 'hashref' => {},
+};
+
+my $imgnum = $arg{'imgnum'} || '';
+die "Bad imgnum" unless $imgnum =~ /^\d*$/;
+$search->{'hashref'}->{'imgnum'} = $imgnum if $imgnum;
+
+$search->{'select'} = 'imgnum, name' if $arg{'no_src'};
+
+$search->{'extra_sql'} = ($imgnum ? ' AND ' : ' WHERE ')
+ . $curuser->agentnums_sql(
+ 'null_right' => ['View global templates','Edit global templates']
+ );
+
+my @images = qsearch($search); #needs agent virtualization
+
+my @result = map { +{
+ 'imgnum' => $_->imgnum,
+ 'name' => $_->name,
+ 'src' => $arg{'no_src'} ? '' : $_->src,
+} } @images;
+
+</%init>