--- s080703/samizdat/data/samizdat/defaults.yaml	2008-07-03 20:19:18.000000000 +0200
+++ defaults.yaml	2008-07-22 23:16:46.000000000 +0200
@@ -85,6 +85,7 @@
   login: 14400   # 4 * 60 * 60 = 4 hours
   last: 1800   # 60 * 30 = 30 minutes
   moderate: 1800   # 60 * 30 = 30 minutes
+  uploads:  1800 # non-confirmed uploads can be deleted after this delay
 
 # Size Limits
 #
@@ -96,6 +97,16 @@
   features_threshold: 1.5   # minimal rating for features
   title: 100   # max characters displayed in a title string
   short: 1000   # max characters in a short message display
+  multi_message: 10  # optional: max number of files to upload at once
+  multi_message_simple: 1  # same, but for the simple mode
+  pending_uploads: 30 # max number of non-confirmed uploads safe from deletion
+
+
+# Insert a thumbnail when using multi_publish for textile or html formats? 
+# See https://savannah.nongnu.org/patch/index.php?6143
+# Comment this out to disable it. Any non-nil value enables it.
+multi_publish_thumbnail: yes  
+
 
 # Supported Message Formats
 #
--- s080703/samizdat/lib/samizdat/controllers/message_controller.rb	2008-05-10 18:35:32.000000000 +0200
+++ /usr/lib/ruby/1.8/samizdat/controllers/message_controller.rb	2008-07-22 23:12:01.000000000 +0200
@@ -94,6 +95,11 @@
   end
 
   def publish
+    if @request.advanced_ui? 
+      @request.redirect('message/multi_publish') 
+    elsif (config['limit']['multi_message_simple'].kind_of? Integer)
+      @request.redirect('message/simple_publish')
+    else
     assert_post
 
     @message = Message.new
@@ -105,6 +111,7 @@
     set_focus
 
     if @request.has_key? 'confirm'
+        set_upload( [:confirm] ) 
       check_content
       save do
         @message.insert!
@@ -117,8 +124,95 @@
       @title = _('New Message')
       @content_for_layout = box(@title, form(nil, *edit_form(:show_focuses)))
     end
+    end  # if @request.advanced_ui? 
+  end
+
+
+  def n_more_simple
+    (config['limit']['multi_message_simple'].kind_of? Integer) ? 
+      config['limit']['multi_message_simple'] : 1 
+  end
+
+  def n_more_multi
+    (config['limit']['multi_message'].kind_of? Integer) ? 
+      config['limit']['multi_message'] : 10
+  end
+
+  def simple_publish  
+    multi_publish_generic(n_more_simple)
+  end
+
+  def multi_publish
+    # how many more messages (usually non-text media items)
+    multi_publish_generic(n_more_multi)
+  end
+
+
+  def multi_publish_generic(n_more_input=1)
+    assert_post
+    @n_more = ( (n_more_input == nil) or 
+                (not n_more_input.kind_of? Integer) or 
+                (n_more_input.to_i < 0) )? 1 : n_more_input
+    
+    @first_message = Message.new
+    @more_messages = []
+
+    for i in -1..(@n_more-1) do 
+      if i == -1
+        @message = @first_message 
+      else
+        @message=Message.new
+      end
+      set_creator
+      set_content 
+      set_lang
+      set_desc
+      set_open
+      set_focus
+      @more_messages += [@message] if i >= 0
+    end
+
+    if @request.has_key? 'confirm'
+      do_thumbnail = true if config['multi_publish_thumbnail']
+      i=0 and ([@first_message] + @more_messages).each do |m|
+        @message = m
+        if ((i += 1) > 1)
+          set_parent(@first_message)
+          @message.content = Content.new(@message.id, @session.login,
+                     @first_message.content.title)
+          set_upload( [:multi_pub, :confirm], i-2 ) # index of uploads starts from 0 
+        end 
+
+        check = nil
+        if i == 1 # a body is required for multi_publish
+          copy = @upload and @upload = false
+          check_content 
+          @upload = copy
+        else
+          check = check_content([:multi_pub, :confirm])
+        end
+
+        if i == 1 or check == 'upload OK'
+          save do
+            @message.insert!
+            update_focus
+          end
+          do_thumbnail = insert_thumbnail(i, check) if do_thumbnail
+        end
+      end #       i=0 and ([@first_message] + @more_messages).each do |m|
+      cache.delete(%{message/#{@first_message_id}})
+      @request.redirect(@first_message.id)
+    elsif @request.has_key? 'preview'
+      @message = @first_message
+      preview([:multi_pub]) 
+    else
+      @title = _('New Message')
+      @content_for_layout = box(@title, 
+             form(nil, *edit_form(:show_focuses,:multi_pub)))
+    end #     if @request.has_key? 'confirm'
   end
 
+
   def reply
     assert_post
 
@@ -131,6 +225,7 @@
     set_open(@parent.open)
 
     if @request.has_key? 'confirm'
+      set_upload( [:confirm] ) 
       check_content
       save do
         @message.insert!
@@ -162,6 +257,7 @@
     set_desc
 
     if @request.has_key? 'confirm'
+      set_upload( [:confirm] ) 
       check_content
       save do
         @message.edit!(@old_content)
@@ -187,6 +283,7 @@
     set_open
 
     if @request.has_key? 'confirm'
+      set_upload( [:confirm] ) 
       check_content
       save('takeover') do
         @message.edit!(@old_content)
@@ -214,6 +311,7 @@
     set_open(false)
 
     if @request.has_key? 'confirm'
+      set_upload( [:confirm] ) 
       check_content
       save('replace') do
         @message.replace!(@old_content)
@@ -277,6 +375,62 @@
 
   private
 
+  def insert_thumbnail(i_multi,check)
+    do_thumbnail = true
+    if i_multi == 1 
+      @first_message_id = @message.id
+      # don't override an image included by the user 
+      do_thumbnail = false if 
+        @message.content.html_full =~ %r{<img[^>]*src[^>]*=} 
+    elsif check == 'upload OK' and 
+        @message.content.format =~ %r{image/}   # the message must be an image
+      # TODO for this whole block: 
+      # reuse existing code (e.g. ImagePlugin) instead of hardwiring it here 
+      path_full = @message.content.file.path(@message.id)
+      path_short = path_full.sub!(%r{\.([a-zA-Z]{3})\z},'.short.\1') # hack
+      relative_url = config['site']['content'] + 
+        @message.content.file.location(@message.id)
+      if File.exists?(path_short) 
+          relative_url.sub!(%r{\.([a-zA-Z]{3})\z},'.short.\1')
+      end
+
+      pre_text = %{<p><a class="image" href="/#{@first_message_id}">} +
+        %{<img style="float:left; margin-right: 20px"} +
+        %{src="#{relative_url}" alt="image #{@message.id}" /></a></p>\n\n}
+      edit_insert(@first_message_id, pre_text)
+      do_thumbnail = false # do only once per multi_publish
+    end
+    do_thumbnail
+  end
+
+  # private method only to be called by multi_publish.*
+  def edit_insert(id,html_text)
+    assert_post
+    @id = id
+    @message = Message.cached(@id)
+    @message.assert_current_version
+    set_creator
+    # set_content
+    title = @message.content.title
+    format = @message.content.format 
+
+    [ 'text/html', 'text/textile' ].include? format or return
+    body = html_text + @message.content.body
+
+    new_content = Content.new(nil, @session.login, title, format, body)
+    @old_content = (@message.content or new_content)
+    @message.content = new_content
+
+    set_lang(@message.lang)
+    set_desc
+    check_content
+    save do
+      @message.edit!(@old_content)
+    end
+  end
+
+
+
   # check if user is allowed to post messages
   #
   def assert_post
@@ -290,10 +444,10 @@
     @message.creator = Member.cached(@session.member)
   end
 
-  def set_parent
-    @parent = Message.cached(@id)
+  def set_parent(parent = nil)
+    @parent = parent ? parent : Message.cached(@id)
     @parent.assert_current_version
-    @message.parent = @id
+    @message.parent = parent ? parent.id : @id
   end
 
   def location_under_parent
@@ -359,47 +513,115 @@
   #
   # returns +true+ if content is handled as upload, +nil+ if it's inline
   #
-  def set_upload
-    file = @request.cgi_params['file'][0]   # just a file object, not its contents
-    return nil unless
-      (file.kind_of? StringIO or file.kind_of? Tempfile) and file.size > 0
+  def set_upload(options = [], index = nil)
+    n_more = (options.include? :multi_pub)? @n_more : 1
+    uploads_found = false
+
+    if (options.include? :multi_pub and options.include? :confirm)
+      # check just one upload
+      return nil if not index
+      file_key   = 'file'+index.to_s 
+      format_key = 'format'+index.to_s 
+      filename = @request.cgi_params[file_key]   # md5sum digest
+      format   = @request.cgi_params[format_key]
 
-    ContentFile.validate_size(file.size)
-
-    content = @message.content
+      if filename and format 
+        @more_messages[index].content.file_old(filename[0],format[0])
+        return true
+      else
+        return nil
+      end
+    elsif (options.include? :confirm)
+      file_key   = 'file'
+      format_key = 'format'
+      filename = @request.cgi_params[file_key]   # md5sum digest
+      format   = @request.cgi_params[format_key]
 
-    if content.format and content.inline?
-      content.body = file.read   # transform to inline message
+      if filename and format 
+        @message.content.file_old(filename[0],format[0])
+        return true
+      else
       return nil
     end
+    end
+
+    for i in 0..(n_more-1) do
+      file_key = (options.include? :multi_pub)? 'file'+i.to_s : 'file'
+      # just a file object, not its contents
+      file = (f = @request.cgi_params[file_key]) ?  f[0] : nil
 
+      if (file.kind_of? StringIO or file.kind_of? Tempfile) and 
+          file.size > 0
+        ContentFile.validate_size(file.size)
+
+        content = @message.content 
+        if content.body == nil and  # usability: don't override the obvious body
+            content.format and content.inline?
+          content.body = file.read   # transform to inline message
+        else
     config.upload_enabled? or raise UserError,
       _('Multimedia upload is disabled on this site')
 
+          if options.include? :multi_pub
+            # left-hand side: 'file=' is a Content method, creates new ContentFile
+            # right-hand side: file is a File object (not ContentFile object)
+            @more_messages[i].content.file = file  
+            uploads_found = true
+          else
     content.file = file
-    return true
+            uploads_found = true
+          end
+        end #       if content.body == nil and 
+      end #       if (file.kind_of? StringIO or ...
+    end #       for i in 0..(@n_more-1) do
+
+    return ( uploads_found ? true : nil )
+  end
+
+  def check_upload(options = [])
+    # in the multi_publish preview stage, only one upload needs to exist
+    if options.include? :multi_pub 
+      if options.include? :preview
+
+        file_exists = false
+        for i in 0..(@n_more-1) do
+          @more_messages[i].content.inline? and
+            raise UserError, 'Unexpected state: inline message upload confirmed'
+          file_exists = true if ( @more_messages[i].content.file and 
+            @more_messages[i].content.file.exists? )
+        end
+        if not file_exists 
+          raise UserError, 'Unexpected state: uploaded file not found'
   end
 
-  def check_upload
+      elsif options.include? :confirm # check just one upload
+        if @message.content.file and @message.content.file.exists?
+          return 'upload OK'
+        else
+          return 'no content'
+        end
+      end
+    else
     @message.content.inline? and
       raise UserError, 'Unexpected state: inline message upload confirmed'
     @message.content.file.exists? or
       raise UserError, 'Unexpected state: uploaded file not found'
   end
+  end
 
-  def check_content
+  def check_content(options = [])
     @message.content.title or raise UserError,
       _('Message title is required')
 
     if @message.content.body.kind_of? String
       persistent_cache.fetch_or_add('antispam:' + site_name) {
         Antispam.new(config['antispam'])
-      }.spam?(@request.role, @message.content.body) and
+      }.spam?(@request.role, @message.content.body, 
+              @message.content.title) and
         raise SpamError, _('Your message looks like spam')
 
     elsif @upload
-      check_upload
-
+      return check_upload(options)
     else
       raise UserError, _('Message body is required')
     end
@@ -418,10 +640,21 @@
       ContentFile.extension(format)
     }.join(', ')
 
+    if options.include? :multi_pub
+       @more_messages.each_index do |i|
+         fields.push([:label, 'file' + i.to_s, 
+                      i == 0 ? 
+                      sprintf(_('Upload content from file ') + 
+                              _('(formats supported: %s)'), formats) :
+                      sprintf(_('Upload content from file'))],
+                     [:file, 'file' + i.to_s]) 
+      end
+    else
     fields.push(
-      [:label, 'file', sprintf(_('Alternatively, upload a file (formats supported: %s)'), formats)],
-        [:file, 'file']
-    )
+         [:label, 'file', 
+         sprintf(_('Alternatively, upload a file (formats supported: %s)'), formats)],
+         [:file, 'file'])
+    end
 
     if options.include? :show_focuses
       # select focus for new message
@@ -461,9 +694,9 @@
     fields
   end
 
-  def preview
-    @upload = set_upload
-    check_content
+  def preview(options = [])
+    @upload = set_upload(options)
+    check_content(options + [:preview])
 
     body = @message.content.body
     if body and body != limit_string(body, config['limit']['short'])
@@ -471,25 +704,53 @@
     end
 
     @title = _('Message Preview')
-    @content_for_layout = box(
-      @message.content.title,
-      message(@message, :full) <<
-        cut_warning.to_s <<
-        '<p>' << _("Press 'Back' button to change the message.") << '</p>' <<
-        secure_form(
-          nil,
+    fields = [
           [:submit, 'confirm', _('Confirm')],
           [:hidden, 'title', @message.content.title],
           [:hidden, 'upload', @upload],
-          [:hidden, 'body', @upload ? nil : body],
+          [:hidden, 'body', 
+           (@upload and not options.include? :multi_pub)? nil : body],
           [:hidden, 'focus', @focus],
           [:hidden, 'lang', @message.lang],
           [:hidden, 'format', @message.content.format],
           [:hidden, 'desc', @message.desc],
           [:hidden, 'open', @message.open],
-          [:hidden, 'action', @action.to_s]
-        )
+          [:hidden, 'action', @action.to_s] ]
+
+    if @upload and options.include? :multi_pub
+      for i in 0..(@n_more-1) do
+        if @more_messages[i].content.file and 
+            @more_messages[i].content.file.exists?
+          fields.push [:hidden, 'file' + i.to_s, 
+                       @more_messages[i].content.file.id[6..37] ] # remove 'upload'
+          fields.push [:hidden, 'format' + i.to_s, 
+                       @more_messages[i].content.file.format ]
+        end
+      end
+    elsif @upload
+      if @message.content.file and 
+          @message.content.file.exists?
+        fields.push [:hidden, 'file', 
+                     @message.content.file.id[6..37] ] # remove 'upload'
+      end
+    end
+
+    @content_for_layout = box(
+      @message.content.title,
+      message(@message, :full) <<
+        cut_warning.to_s <<
+        '<p>' << _("Press 'Back' button to change the message.") << '</p>' <<
+        secure_form(nil, *fields)
     )
+
+    if options.include? :multi_pub
+      @more_messages.each do |m|
+        if m.content.file and m.content.file.exists?
+          @content_for_layout += "\n" + box(m.content.title, message(m, :short))
+        end
+      end
+    end
+      
   end
 
   def update_focus
@@ -510,4 +771,5 @@
       cache.flush
     end
   end
+
 end
--- s080703/samizdat/lib/samizdat/models/content.rb	2008-06-01 20:38:34.000000000 +0200
+++ /usr/lib/ruby/1.8/samizdat/models/content.rb	2008-07-22 23:06:15.000000000 +0200
@@ -173,11 +173,21 @@
     @file = ContentFile.new(self, file)
     @body = nil
   end
+
+  # TODO: add some safety checks for confirmation of old files in
+  # multi_publish messages
+  def file_old(filename,format)
+    @file = ContentFile.new(self, nil, filename, format)
+    @body = nil
+  end
+
 end
 
 
 class ContentFile
 
+  @@etch_hack = true # for the debian stable distribution 4.0, called "etch"
+
   # check if size is within the configured limit
   #
   def ContentFile.validate_size(size)
@@ -189,13 +199,23 @@
     end
   end
 
-  def initialize(content, file = nil)
-    @id = (content.id or upload_id)
+  def initialize(content, file = nil, 
+                 recover_filename = nil, recover_format = nil)
+
+    @id = recover_filename ? 
+        'upload' + recover_filename : (content.id or upload_id)
     @login = content.login
 
     if file   # new upload
+      if check_and_delete_pending_uploads
       save(file)
+      else
+        raise UserError, _('Too many files have recently been uploaded to the preview stage without being confirmed. Please wait and try again later')
+      end
       content.format = @format
+    elsif recover_format
+      @format = recover_format
+      content.format = recover_format
     else
       @format = content.format
     end
@@ -210,7 +230,7 @@
   attr_reader :id, :login, :format
 
   def ContentFile.extension(format)
-    config['file_extension'][format] or format.sub(%r{\A.*/(x-)?}, '')
+    config['file_extension'][format] or format.to_s.sub(%r{\A.*/(x-)?}, '')
   end
 
   def extension
@@ -269,22 +289,33 @@
   # id component of the file path of a new upload
   #
   def upload_id
-    'upload'
+    'upload' + random_digest
   end
 
   def validate_id(id)
-    id.kind_of?(Integer) or upload_id == id or raise RuntimeError,
+    id.kind_of?(Integer) or 
+      id.to_s =~ %r{\Aupload[0-9a-fA-F]{32}\z} or
+      raise RuntimeError,
       "Unexpected file upload id (#{id.inspect})"
   end
 
   # detect and validate format from upload
   #
   def detect_file_format(file)
-    if file.respond_to?(:path) and file.path
+    if (not @@etch_hack) and file.respond_to?(:path) and file.path
       @@mahoro ||= Mahoro.new(Mahoro::MIME)
       format = @@mahoro.file(file.path)
     end
 
+    # etch hack: mahoro fails on etch in several legitimate cases, 
+    # so revert to 070818 algorithm
+    if @@etch_hack and @file_content_type_etch_hack and file.size > 0
+      format = @file_content_type_etch_hack
+      format = 'image/jpeg' if format == 'image/pjpeg'   # MSIE...
+      format = 'image/png' if format == 'image/x-png'
+    end
+
+
     format.nil? and raise RuntimeError,
       _('Failed to detect content type of the uploaded file')
 
@@ -300,6 +331,11 @@
   end
 
   def save(file)
+    if @@etch_hack  # record content_type before copying to tmp
+      @file_content_type_etch_hack = 
+        (file.methods.include? 'content_type')? 
+      file.content_type.strip : 'unknown'  
+    end
     if file.kind_of?(StringIO)
       # make it a real file 
       tmp = Tempfile.new('samizdat-' + random_digest)
@@ -316,4 +352,55 @@
     mkdir(File.dirname(destination))
     FileUtils.mv(file.path, destination)
   end
+
+
+  # returns false if there are too many recent files
+  def check_and_delete_pending_uploads
+    max_pending_uploads = (config['limit']['pending_uploads'] or 100)
+    uploads_timeout = (config['timeout']['uploads'] or 1800)
+    if (c = config['limit']['multi_message'])
+      max_pending_uploads = c if c >  max_pending_uploads
+    end
+    if (c = config['limit']['multi_message_simple'])
+      max_pending_uploads = c if c >  max_pending_uploads
+    end
+
+    upload_glob = config.content_dir + '/' + @login.to_s + 
+      '/upload' + Array.new(32,'[0-9a-fA-F]').to_s + '*'
+    uploads = Dir[upload_glob] or raise RuntimeError, 
+      'Pending uploads error'
+   
+    n_uploads = uploads.size
+    n_to_be_deleted = n_uploads - max_pending_uploads
+    if n_to_be_deleted > 0
+      now = Time.now.to_i # in seconds
+      uploads_ages = []  # TODO: what's the ruby way to do this?
+      uploads.each do |f|
+        uploads_ages += [[ f, now - File.mtime(f.untaint).to_i ]] 
+      end
+      i=0
+      uploads_ages.sort!{ |a,b| b[1] <=> a[1] } 
+      for i in 0..(n_to_be_deleted-1) do
+        if uploads_ages[i][1] > uploads_timeout 
+          # TODO: Should the deletes be logged?
+          # log(sprintf("ContentFile.check_and_delete_pending_uploads:\n" +
+          # "Will delete: %s", uploads_ages[i][0])) 
+          File.delete( uploads_ages[i][0] )
+        end
+      end
+    end
+
+    # result is acceptable if the files really got deleted
+    # and if enough of them were deleted
+    uploads = Dir[upload_glob] or raise RuntimeError, 
+      'Pending uploads error'
+    n_uploads = uploads.size
+
+    if n_uploads > max_pending_uploads
+      false 
+    else
+      true
+    end
+  end
+
 end
