#!/usr/bin/env ruby # # vim: set sw=2 ts=2 expandtab: # # Copyright (C) 2010 by James Maki # # Author: James Maki # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # #Thread.abort_on_exception = true require 'socket' require 'inform' require 'yaml' require 'webrick' require 'base64' require 'rexml/document' module SMSUtils SMS_ERR_LOG = "sms.err.log" module_function def init() @sms_mutex = Mutex.new File.unlink(SMS_ERR_LOG) rescue nil sms_init() sms_load_phonebook() end def sms_cmd(cmd) cmd = "sms #{cmd} 2>>#{SMS_ERR_LOG}" Inform.debug "issuing command: #{cmd}" @sms_mutex.synchronize { IO.popen(cmd, "w+") { |pipe| if block_given? yield pipe else data = pipe.read Inform.debug "read: #{data.inspect}" end } } unless $?.success? raise "failure: #{cmd}" else Inform.debug "success: #{cmd}" end true end def sms_init() sms_cmd("sms-init") end def sms_load_phonebook data = nil sms_cmd("pb-list") { |pipe| data = pipe.read } @phonebook = YAML.load(data) end def phonebook @phonebook end end module WebUtils private def pcdata(data) data = data.gsub(/&/, "&") data.gsub!(//, ">") return data end def sql_escape(str) return nil unless str return str.gsub(/'/, "''") end # Performs URI escaping so that you can construct proper # query strings faster. Use this rather than the cgi.rb # version since it's faster. (Stolen from Camping). def url_escape(s) s.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/n) { '%'+$1.unpack('H2'*$1.size).join('%').upcase }.tr(' ', '+') end module_function :url_escape # Unescapes a URI escaped string. (Stolen from Camping). def url_unescape(s) s.tr('+', ' ').gsub(/((?:%[0-9a-fA-F]{2})+)/n){ [$1.delete('%')].pack('H*') } end module_function :url_unescape # Stolen from Mongrel: # Parses a query string by breaking it up at the '&' # and ';' characters. You can also use this to parse # cookies by changing the characters used in the second # parameter (which defaults to '&;'). def parse_query(qs, d = '&;') params = {} (qs||'').split(/[#{d}] */n).inject(params) { |h,p| k, v=url_unescape(p).split('=',2) if cur = params[k] if cur.class == Array params[k] << v else params[k] = [cur, v] end else params[k] = v end } return params end module_function :parse_query def query_params if @_qp @_qp else @_qp = parse_query(request.query_string) end end def uri(path) uri = URI.parse(request.request_uri.to_s) uri.path = path uri.query = nil uri.to_s end def xml_decl rv = REXML::XMLDecl.new("1.1", "UTF-8") rv.dowrite return rv end def xml_response(message, xml) resp = <<-EOM #{pcdata message} #{xml} EOM return resp end def verify_identity request['Authorization'] =~ /^Basic (.+)/ auth = $1 respond_unauthorized unless auth user, passwd = Base64.decode64(auth).split(":", 2) respond_unauthorized unless user && passwd if user != $config["smsws"]["user"] || passwd != $config["smsws"]["passwd"] respond_unauthorized end return true end [ {:method => 'ok', :code => 200, :message => 'OK'}, {:method => 'created', :code => 201, :message => 'Created'}, {:method => 'accepted', :code => 202, :message => 'Accepted'}, {:method => 'bad_request', :code => 400, :message => 'Bad Request'}, {:method => 'unauthorized', :code => 401, :message => 'Unauthorized'}, {:method => 'forbidden', :code => 403, :message => 'Forbidden'}, {:method => 'not_found', :code => 404, :message => 'Not Found'}, {:method => 'method_not_allowed', :code => 405, :message => 'Method Not Allowed'}, {:method => 'conflict', :code => 409, :message => 'Conflict'}, {:method => 'gone', :code => 410, :message => 'Gone'}, {:method => 'too_large', :code => 413, :message => 'Request Entity Too Large'}, {:method => 'unsupported_media_type', :code => 415, :message => 'Unsupported Media Type'}, {:method => 'internal_server_error', :code => 500, :message => 'Internal Server Error'}, {:method => 'not_implemented', :code => 501, :message => 'Not Implemented'}, {:method => 'service_unavailable', :code => 503, :message => 'Service Unavailable'}, ].each do |resp| module_eval <<-EOS def respond_#{resp[:method]}(options = {}) options = { :code => #{resp[:code]}, :message => '#{resp[:message]}' }.merge(options) respond_with(options) end EOS end def respond_with(options = {}) options = { :code => 200, :message => 'OK', }.merge(options) response['Content-Type'] = 'application/xml' response['Allow'] = options[:allow] if options[:allow] if options[:code] == 401 response['WWW-Authenticate'] = %(Basic realm="FFWS Authentication required") end xml = xml_response(options[:message], options[:xml]) respond xml, options[:code] end def response @response end def request @request end def handle(request, response) @response = response @request = request catch(:respond) { verify_identity() begin yield rescue Exception => e respond_internal_server_error(:message => e.to_s) end } end def respond(body, status) response.body = body response.status = status throw :respond end end SMSWS_PATH = '/smsws/v1' class SMSPhonebook < WEBrick::HTTPServlet::AbstractServlet include WebUtils PATH = "#{SMSWS_PATH}/phonebook" def do_GET(request, response) handle(request, response) { phonebook_get() } end def entry_to_xml(entry) return < #{uri("#{PATH}/#{entry["index"]}")} #{entry["type"]} #{entry["addr"]} #{pcdata entry["name"]} EOM end def entry_index len = PATH.split('/').length parts = request.path.split('/') parts.shift(len) index = parts[0] index = index.to_i if index index end def phonebook_get index = entry_index() xml = "" list = SMSUtils.phonebook["list"] list = [] unless list list.each do |entry| unless index xml << entry_to_xml(entry) else if index == entry["index"] xml << entry_to_xml(entry) break end end end respond_ok(:message => "Success", :xml => xml) end end class SMSMessages < WEBrick::HTTPServlet::AbstractServlet include WebUtils PATH = "#{SMSWS_PATH}/messages" def do_GET(request, response) handle(request, response) { messages_get() } end def do_DELETE(request, response) handle(request, response) { messages_delete() } end def message_to_xml(message) return < #{uri("#{PATH}/#{message["index"]}")} #{message["message-status"]} #{message["pdu"]["addr-type"]} #{message["pdu"]["addr"]} #{Base64.encode64(message["pdu"]["user-data"])} EOM end def message_index len = PATH.split('/').length parts = request.path.split('/') parts.shift(len) parts[0] index = index.to_i if index index end def messages_get index = message_index() data = nil if index SMSUtils.sms_cmd("read #{index}") { |pipe| data = pipe.read } else status = query_params["status"] Inform.debug "status is #{status}" status = "all" unless status SMSUtils.sms_cmd("list #{status}") { |pipe| data = pipe.read } end document = YAML.load(data) messages = document["messages"] messages = [] unless messages xml = "" messages.each do |message| xml << message_to_xml(message) end respond_ok(:message => "Success", :xml => xml) end def messages_delete index = message_index() data = nil if index SMSUtils.sms_cmd("delete index #{index}") { |pipe| data = pipe.read } else status = query_params["status"] Inform.debug "status is #{status}" status = "all" unless status SMSUtils.sms_cmd("delete #{status}") { |pipe| data = pipe.read } end respond_ok(:message => "Deleted") end end class SMSSend < WEBrick::HTTPServlet::AbstractServlet include WebUtils PATH = "#{SMSWS_PATH}/send" def do_POST(request, response) handle(request, response) { send_post() } end def send_post document = REXML::Document.new(request.body) root = document.root addr = nil alphabet = "seven-bit" user_data = "" raise ArgumentError, "element is not sms-message" unless root && root.name == "sms-message" root.each_element { |element| case element.name when "addr" addr = element.text if element.has_text? when "alphabet" alphabet = element.text if element.has_text? when "user-data" user_data = element.text if element.has_text? user_data = Base64.decode64(user_data) end } raise ArgumentError, "addr missing" unless addr if addr =~ /[^1234567890*#+-.]/ list = SMSUtils.phonebook["list"] list = [] unless list list.each do |entry| if addr == entry["name"] Inform.debug "#{entry["name"]} => #{entry["addr"]}" addr = entry["addr"] end end end SMSUtils.sms_cmd("send --alphabet #{alphabet} #{addr}") { |pipe| pipe.write(user_data) } respond_created(:message => "Sent") end end class SMSHelp < WEBrick::HTTPServlet::AbstractServlet include WebUtils def do_GET(request, response) response['Content-Type'] = 'text/plain' response.body = <<'EOM' SMS Web Service Examples Messages: opening connection to 192.168.2.1... opened <- "GET /smsws/v1/messages HTTP/1.1\r\nAccept: */*\r\nUser-Agent: smswsc/0.1\r\nContent-Type: application/xml\r\nAuthorization: Basic dXNlcjpwYXNzd2Q=\r\nHost: 192.168.2.1:5857\r\n\r\n" -> "HTTP/1.1 200 OK \r\n" -> "Connection: Keep-Alive\r\n" -> "Content-Type: application/xml\r\n" -> "Date: Mon, 10 May 2010 18:24:26 GMT\r\n" -> "Server: WEBrick/1.3.1 (Ruby/1.8.7/2009-12-24)\r\n" -> "Content-Length: 522\r\n" -> "\r\n" reading 522 bytes... -> "" -> "\n\n Success\n\n http://192.168.2.1:5857/smsws/v1/messages/1\n 3\n 128\n \n \ndGVzdA==\n\n \n\n\n http://192.168.2.1:5857/smsws/v1/messages/2\n 1\n 145\n 13204931234\n \n\n \n\n\n\n" read 522 bytes Conn keep-alive [#, @addr="", @user_data="test", @addr_type="128">, #, @addr="13204931234", @user_data="", @addr_type="145">] Phonebook: opening connection to 192.168.2.1... opened <- "GET /smsws/v1/phonebook HTTP/1.1\r\nAccept: */*\r\nUser-Agent: smswsc/0.1\r\nContent-Type: application/xml\r\nAuthorization: Basic dXNlcjpwYXNzd2Q=\r\nHost: 192.168.2.1:5857\r\n\r\n" -> "HTTP/1.1 200 OK \r\n" -> "Connection: Keep-Alive\r\n" -> "Content-Type: application/xml\r\n" -> "Date: Mon, 10 May 2010 18:24:27 GMT\r\n" -> "Server: WEBrick/1.3.1 (Ruby/1.8.7/2009-12-24)\r\n" -> "Content-Length: 445\r\n" -> "\r\n" reading 445 bytes... -> "" -> "\n\n Success\n\n http://192.168.2.1:5857/smsws/v1/phonebook/1\n 129\n 3204931234\n jcm\n\n\n http://192.168.2.1:5857/smsws/v1/phonebook/2\n 129\n 3204931234\n test\n\n\n\n" read 445 bytes Conn keep-alive [#, @addr="3204931234", @name="jcm", @addr_type="129">, #, @addr="3204931234", @name="test", @addr_type="129">] Sending: opening connection to 192.168.2.1... opened <- "POST /smsws/v1/send HTTP/1.1\r\nAccept: */*\r\nUser-Agent: smswsc/0.1\r\nContent-Type: application/xml\r\nAuthorization: Basic dXNlcjpwYXNzd2Q=\r\nContent-Length: 184\r\nHost: 192.168.2.1:5857\r\n\r\n" <- "3204931234dGVzdCBtZSB3ZWI=\n" -> "HTTP/1.1 201 Created \r\n" -> "Connection: Keep-Alive\r\n" -> "Content-Type: application/xml\r\n" -> "Date: Mon, 10 May 2010 18:24:29 GMT\r\n" -> "Server: WEBrick/1.3.1 (Ruby/1.8.7/2009-12-24)\r\n" -> "Content-Length: 103\r\n" -> "\r\n" reading 103 bytes... -> "" -> "\n\n Sent\n\n\n" read 103 bytes Conn keep-alive EOM response.status = 200 end end if $0 == __FILE__ then SMSWS_ROOT = File.expand_path(File.dirname(__FILE__)) Dir.chdir(SMSWS_ROOT) SMSWS_CONFIG = "#{SMSWS_ROOT}/sms.config" $config = YAML::load_file(SMSWS_CONFIG) ENV['SMS_CONFIG'] = "#{SMSWS_CONFIG}" Inform.syslog = false if Inform.syslog Syslog.open("smsws", Syslog::LOG_NDELAY, Syslog::LOG_LOCAL7) else Inform.level = Inform::LOG_ERR #Inform.level = Inform::LOG_DEBUG Inform.file = $stdout end SMSUtils.init() server = WEBrick::HTTPServer.new(:Port => $config["smsws"]["port"]) server.mount "/", SMSHelp server.mount SMSPhonebook::PATH, SMSPhonebook server.mount SMSMessages::PATH, SMSMessages server.mount SMSSend::PATH, SMSSend trap "INT" do server.shutdown end server.start end