OSDN Git Service

Merge commit '2458eff3aea04f67893bc824b5cf896fbb767332'
[jindolf/Jindolf.git] / src / main / java / jp / sourceforge / jindolf / ServerAccess.java
diff --git a/src/main/java/jp/sourceforge/jindolf/ServerAccess.java b/src/main/java/jp/sourceforge/jindolf/ServerAccess.java
new file mode 100644 (file)
index 0000000..4a54131
--- /dev/null
@@ -0,0 +1,617 @@
+/*\r
+ * manage HTTP access\r
+ *\r
+ * Copyright(c) 2008 olyutorskii\r
+ * $Id: ServerAccess.java 993 2010-03-14 11:55:46Z olyutorskii $\r
+ */\r
+\r
+package jp.sourceforge.jindolf;\r
+\r
+import java.awt.image.BufferedImage;\r
+import java.io.IOException;\r
+import java.io.InputStream;\r
+import java.io.OutputStream;\r
+import java.io.UnsupportedEncodingException;\r
+import java.lang.ref.SoftReference;\r
+import java.net.HttpURLConnection;\r
+import java.net.MalformedURLException;\r
+import java.net.Proxy;\r
+import java.net.URL;\r
+import java.net.URLEncoder;\r
+import java.nio.charset.Charset;\r
+import java.util.Collections;\r
+import java.util.HashMap;\r
+import java.util.Map;\r
+import javax.imageio.ImageIO;\r
+import jp.sourceforge.jindolf.parser.ContentBuilder;\r
+import jp.sourceforge.jindolf.parser.ContentBuilderSJ;\r
+import jp.sourceforge.jindolf.parser.ContentBuilderUCS2;\r
+import jp.sourceforge.jindolf.parser.DecodeException;\r
+import jp.sourceforge.jindolf.parser.DecodedContent;\r
+import jp.sourceforge.jindolf.parser.SjisDecoder;\r
+import jp.sourceforge.jindolf.parser.StreamDecoder;\r
+\r
+/**\r
+ * 国ごとの人狼BBSサーバとの通信を一手に引き受ける。\r
+ */\r
+public class ServerAccess{\r
+\r
+    private static final String USER_AGENT = HttpUtils.getUserAgentName();\r
+    private static final String JINRO_CGI = "./index.rb";\r
+    private static final\r
+            Map<String, SoftReference<BufferedImage>> IMAGE_CACHE;\r
+\r
+    static{\r
+        Map<String, SoftReference<BufferedImage>> cache =\r
+                new HashMap<String, SoftReference<BufferedImage>>();\r
+        IMAGE_CACHE = Collections.synchronizedMap(cache);\r
+    }\r
+\r
+    /**\r
+     * 画像キャッシュを検索する。\r
+     * @param key キー\r
+     * @return キャッシュされた画像。キャッシュされていなければnull。\r
+     */\r
+    private static BufferedImage getImageCache(String key){\r
+        if(key == null) return null;\r
+\r
+        BufferedImage image;\r
+\r
+        synchronized(IMAGE_CACHE){\r
+            SoftReference<BufferedImage> ref = IMAGE_CACHE.get(key);\r
+            if(ref == null) return null;\r
+\r
+            Object referent = ref.get();\r
+            if(referent == null){\r
+                IMAGE_CACHE.remove(key);\r
+                return null;\r
+            }\r
+\r
+            image = (BufferedImage) referent;\r
+        }\r
+\r
+        return image;\r
+    }\r
+\r
+    /**\r
+     * 画像キャッシュに登録する。\r
+     * @param key キー\r
+     * @param image キャッシュしたい画像。\r
+     */\r
+    private static void putImageCache(String key, BufferedImage image){\r
+        if(key == null || image == null) return;\r
+\r
+        synchronized(IMAGE_CACHE){\r
+            if(getImageCache(key) != null) return;\r
+            SoftReference<BufferedImage> ref =\r
+                    new SoftReference<BufferedImage>(image);\r
+            IMAGE_CACHE.put(key, ref);\r
+        }\r
+\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * 与えられた文字列に対し「application/x-www-form-urlencoded」符号化を行う。\r
+     * この符号化はHTTPのPOSTメソッドで必要になる。\r
+     * この処理は、一般的なPC用Webブラウザにおける、\r
+     * Shift_JISで書かれたHTML文書のFORMタグに伴う\r
+     * submit処理を模倣する。\r
+     * @param formData 元の文字列\r
+     * @return 符号化された文字列\r
+     */\r
+    public static String formEncode(String formData){\r
+        if(formData == null){\r
+            return null;\r
+        }\r
+        String result;\r
+        try{\r
+            result = URLEncoder.encode(formData, "US-ASCII");\r
+        }catch(UnsupportedEncodingException e){\r
+            assert false;\r
+            result = null;\r
+        }\r
+        return result;\r
+    }\r
+\r
+    /**\r
+     * 配列版formEncode。\r
+     * @param formData 元の文字列\r
+     * @return 符号化された文字列\r
+     */\r
+    public static String formEncode(char[] formData){\r
+        return formEncode(new String(formData));\r
+    }\r
+\r
+    private final URL baseURL;\r
+    private final Charset charset;\r
+    private Proxy proxy = Proxy.NO_PROXY;\r
+    private long lastServerMs;\r
+    private long lastLocalMs;\r
+    private long lastSystemMs;\r
+    private AccountCookie cookieAuth = null;\r
+    private String encodedUserID = null;\r
+\r
+    /**\r
+     * 人狼BBSサーバとの接続管理を生成する。\r
+     * この時点ではまだ通信は行われない。\r
+     * @param baseURL 国別のベースURL\r
+     * @param charset 国のCharset\r
+     */\r
+    public ServerAccess(URL baseURL, Charset charset){\r
+        this.baseURL = baseURL;\r
+        this.charset = charset;\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * HTTP-Proxyを返す。\r
+     * @return HTTP-Proxy\r
+     */\r
+    public Proxy getProxy(){\r
+        return this.proxy;\r
+    }\r
+\r
+    /**\r
+     * HTTP-Proxyを設定する。\r
+     * @param proxy HTTP-Proxy。nullならProxyなしと解釈される。\r
+     */\r
+    public void setProxy(Proxy proxy){\r
+        if(proxy == null) this.proxy = Proxy.NO_PROXY;\r
+        else              this.proxy = proxy;\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * 国のベースURLを返す。\r
+     * @return ベースURL\r
+     */\r
+    public URL getBaseURL(){\r
+        return this.baseURL;\r
+    }\r
+\r
+    /**\r
+     * 与えられたクエリーとCGIのURLから新たにURLを合成する。\r
+     * @param query クエリー\r
+     * @return 新たなURL\r
+     */\r
+    protected URL getQueryURL(String query){\r
+        if(query.length() >= 1 && query.charAt(0) != '?'){\r
+            return null;\r
+        }\r
+\r
+        URL result;\r
+        try{\r
+            result = new URL(getBaseURL(), JINRO_CGI + query);\r
+        }catch(MalformedURLException e){\r
+            assert false;\r
+            return null;\r
+        }\r
+        return result;\r
+    }\r
+\r
+    /**\r
+     * 「Shift_JIS」でエンコーディングされた入力ストリームから文字列を生成する。\r
+     * @param istream 入力ストリーム\r
+     * @return 文字列\r
+     * @throws java.io.IOException 入出力エラー(おそらくネットワーク関連)\r
+     */\r
+    public DecodedContent downloadHTMLStream(InputStream istream)\r
+            throws IOException{\r
+        StreamDecoder decoder;\r
+        ContentBuilder builder;\r
+        if(this.charset.name().equalsIgnoreCase("Shift_JIS")){\r
+            decoder = new SjisDecoder();\r
+            builder = new ContentBuilderSJ(200 * 1024);\r
+        }else if(this.charset.name().equalsIgnoreCase("UTF-8")){\r
+            decoder = new StreamDecoder(this.charset.newDecoder());\r
+            builder = new ContentBuilderUCS2(200 * 1024);\r
+        }else{\r
+            assert false;\r
+            return null;\r
+        }\r
+        decoder.setDecodeHandler(builder);\r
+\r
+        // TODO デコーダをインスタンス変数にできないか。\r
+        // TODO DecodedContentのキャッシュ管理。\r
+\r
+        try{\r
+            decoder.decode(istream);\r
+        }catch(DecodeException e){\r
+            return null;\r
+        }\r
+\r
+        return builder.getContent();\r
+    }\r
+\r
+    /**\r
+     * 与えられたクエリーを用いてHTMLデータを取得する。\r
+     * @param query HTTP-GET クエリー\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    protected HtmlSequence downloadHTML(String query)\r
+            throws IOException{\r
+        URL url = getQueryURL(query);\r
+        HtmlSequence result = downloadHTML(url);\r
+        return result;\r
+    }\r
+\r
+    /**\r
+     * 与えられたURLを用いてHTMLデータを取得する。\r
+     * @param url URL\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    protected HtmlSequence downloadHTML(URL url)\r
+            throws IOException{\r
+        HttpURLConnection connection =\r
+                (HttpURLConnection) url.openConnection(this.proxy);\r
+        connection.setRequestProperty("Accept", "*/*");\r
+        connection.setRequestProperty("User-Agent", USER_AGENT);\r
+        connection.setUseCaches(false);\r
+        connection.setInstanceFollowRedirects(false);\r
+        connection.setDoInput(true);\r
+        connection.setRequestMethod("GET");\r
+\r
+        AccountCookie cookie = this.cookieAuth;\r
+        if(cookie != null){\r
+            if(shouldAccept(url, cookie)){\r
+                connection.setRequestProperty(\r
+                        "Cookie",\r
+                        "login=" + cookie.getLoginData());\r
+            }else{\r
+                clearAuthentication();\r
+            }\r
+        }\r
+\r
+        connection.connect();\r
+\r
+        long datems = updateLastAccess(connection);\r
+\r
+        int responseCode = connection.getResponseCode();\r
+        if(responseCode != HttpURLConnection.HTTP_OK){ // 200\r
+            String logMessage =  "発言のダウンロードに失敗しました。";\r
+            logMessage += HttpUtils.formatHttpStat(connection, 0, 0);\r
+            Jindolf.logger().warn(logMessage);\r
+            return null;\r
+        }\r
+\r
+        String cs = HttpUtils.getHTMLCharset(connection);\r
+        if(!cs.equalsIgnoreCase(this.charset.name())){\r
+            return null;\r
+        }\r
+\r
+        InputStream stream = TallyInputStream.getInputStream(connection);\r
+        DecodedContent html = downloadHTMLStream(stream);\r
+\r
+        stream.close();\r
+        connection.disconnect();\r
+\r
+        HtmlSequence hseq = new HtmlSequence(url, datems, html);\r
+\r
+        return hseq;\r
+    }\r
+\r
+    /**\r
+     * 絶対または相対URLの指すパーマネントなイメージ画像をダウンロードする。\r
+     * @param url 画像URL文字列\r
+     * @return 画像イメージ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public BufferedImage downloadImage(String url) throws IOException{\r
+        URL absolute;\r
+        try{\r
+            URL base = getBaseURL();\r
+            absolute = new URL(base, url);\r
+        }catch(MalformedURLException e){\r
+            assert false;\r
+            return null;\r
+        }\r
+\r
+        BufferedImage image;\r
+        image = getImageCache(absolute.toString());\r
+        if(image != null) return image;\r
+\r
+        HttpURLConnection connection =\r
+                (HttpURLConnection) absolute.openConnection(this.proxy);\r
+        connection.setRequestProperty("Accept", "*/*");\r
+        connection.setRequestProperty("User-Agent", USER_AGENT);\r
+        connection.setUseCaches(true);\r
+        connection.setInstanceFollowRedirects(true);\r
+        connection.setDoInput(true);\r
+        connection.setRequestMethod("GET");\r
+\r
+        connection.connect();\r
+\r
+        int responseCode       = connection.getResponseCode();\r
+        if(responseCode != HttpURLConnection.HTTP_OK){\r
+            String logMessage =  "イメージのダウンロードに失敗しました。";\r
+            logMessage += HttpUtils.formatHttpStat(connection, 0, 0);\r
+            Jindolf.logger().warn(logMessage);\r
+            return null;\r
+        }\r
+\r
+        InputStream stream = TallyInputStream.getInputStream(connection);\r
+        image = ImageIO.read(stream);\r
+        stream.close();\r
+\r
+        connection.disconnect();\r
+\r
+        putImageCache(absolute.toString(), image);\r
+\r
+        return image;\r
+    }\r
+\r
+    /**\r
+     * 指定された認証情報をPOSTする。\r
+     * @param authData 認証情報\r
+     * @return 認証情報が受け入れられたらtrue\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    protected boolean postAuthData(String authData) throws IOException{\r
+        URL url = getQueryURL("");\r
+        HttpURLConnection connection =\r
+                (HttpURLConnection) url.openConnection(this.proxy);\r
+        connection.setRequestProperty("Accept", "*/*");\r
+        connection.setRequestProperty("User-Agent", USER_AGENT);\r
+        connection.setUseCaches(false);\r
+        connection.setInstanceFollowRedirects(false);\r
+        connection.setDoInput(true);\r
+        connection.setDoOutput(true);\r
+        connection.setRequestMethod("POST");\r
+\r
+        byte[] authBytes = authData.getBytes();\r
+\r
+        OutputStream os = TallyOutputStream.getOutputStream(connection);\r
+        os.write(authBytes);\r
+        os.flush();\r
+        os.close();\r
+\r
+        updateLastAccess(connection);\r
+\r
+        int responseCode = connection.getResponseCode();\r
+        if(responseCode != HttpURLConnection.HTTP_MOVED_TEMP){    // 302\r
+            String logMessage =  "認証情報の送信に失敗しました。";\r
+            Jindolf.logger().warn(logMessage);\r
+            connection.disconnect();\r
+            return false;\r
+        }\r
+\r
+        connection.disconnect();\r
+\r
+        AccountCookie loginCookie = AccountCookie.createCookie(connection);\r
+        if(loginCookie == null){\r
+            return false;\r
+        }\r
+\r
+        setAuthentication(loginCookie);\r
+\r
+        Jindolf.logger().info("正しく認証が行われました。");\r
+\r
+        return true;\r
+    }\r
+\r
+    /**\r
+     * トップページのHTMLデータを取得する。\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public HtmlSequence getHTMLTopPage() throws IOException{\r
+        return downloadHTML("");\r
+    }\r
+\r
+    /**\r
+     * 国に含まれる村一覧HTMLデータを取得する。\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public HtmlSequence getHTMLLandList() throws IOException{\r
+        return downloadHTML("?cmd=log");\r
+    }\r
+\r
+    /**\r
+     * 指定された村のPeriod一覧のHTMLデータを取得する。\r
+     * 現在ゲーム進行中の村にも可能。\r
+     * ※ 古国では使えないよ!\r
+     * @param village 村\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public HtmlSequence getHTMLBoneHead(Village village) throws IOException{\r
+        String villageID = village.getVillageID();\r
+        return downloadHTML("?vid=" + villageID + "&meslog=");\r
+    }\r
+\r
+    /**\r
+     * 指定された村の最新PeriodのHTMLデータをロードする。\r
+     * 既にGAMEOVERの村ではPeriod一覧のHTMLデータとなる。\r
+     * @param village 村\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public HtmlSequence getHTMLVillage(Village village) throws IOException{\r
+        URL url = getVillageURL(village);\r
+        return downloadHTML(url);\r
+    }\r
+\r
+    /**\r
+     * 指定された村の最新PeriodのHTMLデータのURLを取得する。\r
+     * @param village 村\r
+     * @return URL\r
+     */\r
+    public URL getVillageURL(Village village){\r
+        String villageID = village.getVillageID();\r
+        URL url = getQueryURL("?vid=" + villageID);\r
+        return url;\r
+    }\r
+\r
+    /**\r
+     * 指定されたPeriodのHTMLデータをロードする。\r
+     * @param period Period\r
+     * @return HTMLデータ\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public HtmlSequence getHTMLPeriod(Period period) throws IOException{\r
+        URL url = getPeriodURL(period);\r
+        return downloadHTML(url);\r
+    }\r
+\r
+    /**\r
+     * 指定されたPeriodのHTMLデータのURLを取得する。\r
+     * @param period 日\r
+     * @return URL\r
+     */\r
+    public URL getPeriodURL(Period period){\r
+        String query = period.getCGIQuery();\r
+        URL url = getQueryURL(query);\r
+        return url;\r
+    }\r
+\r
+    /**\r
+     * 最終アクセス時刻を更新する。\r
+     * @param connection HTTP接続\r
+     * @return リソース送信時刻\r
+     */\r
+    public long updateLastAccess(HttpURLConnection connection){\r
+        this.lastServerMs = connection.getDate();\r
+        this.lastLocalMs = System.currentTimeMillis();\r
+        this.lastSystemMs = System.nanoTime() / (1000 * 1000);\r
+        return this.lastServerMs;\r
+    }\r
+\r
+    /**\r
+     * 指定したURLに対しCookieを送っても良いか否か判定する。\r
+     * 判別材料は Cookie の寿命とパス指定のみ。\r
+     * @param url URL\r
+     * @param cookie Cookie\r
+     * @return 送ってもよければtrue\r
+     */\r
+    private static boolean shouldAccept(URL url, AccountCookie cookie){\r
+        if(cookie.hasExpired()){\r
+            return false;\r
+        }\r
+\r
+        String urlPath = url.getPath();\r
+        String cookiePath = cookie.getPathURI().getPath();\r
+\r
+        if( ! urlPath.startsWith(cookiePath) ){\r
+            return false;\r
+        }\r
+\r
+        return true;\r
+    }\r
+\r
+    /**\r
+     * 現在ログイン中か否か判別する。\r
+     * @return ログイン中ならtrue\r
+     */\r
+    // TODO interval call\r
+    public boolean hasLoggedIn(){\r
+        AccountCookie cookie = this.cookieAuth;\r
+        if(cookie == null){\r
+            return false;\r
+        }\r
+        if(cookie.hasExpired()){\r
+            clearAuthentication();\r
+            return false;\r
+        }\r
+        return true;\r
+    }\r
+\r
+    /**\r
+     * 与えられたユーザIDとパスワードでログイン処理を行う。\r
+     * @param userID ユーザID\r
+     * @param password パスワード\r
+     * @return ログインに成功すればtrue\r
+     * @throws java.io.IOException ネットワークエラー\r
+     */\r
+    public final boolean login(String userID, char[] password)\r
+            throws IOException{\r
+        if(hasLoggedIn()){\r
+            return true;\r
+        }\r
+\r
+        String id = formEncode(userID);\r
+        if(id == null || id.length() <= 0){\r
+            return false;\r
+        }\r
+\r
+        String pw = formEncode(password);\r
+        if(pw == null || pw.length() <= 0){\r
+            return false;\r
+        }\r
+\r
+        this.encodedUserID = id;\r
+\r
+        String redirect = formEncode("&#bottom");   // TODO ほんとに必要?\r
+\r
+        StringBuilder postData = new StringBuilder();\r
+        postData.append("cmd=login");\r
+        postData.append('&').append("cgi_param=").append(redirect);\r
+        postData.append('&').append("user_id=").append(id);\r
+        postData.append('&').append("password=").append(pw);\r
+\r
+        boolean result;\r
+        try{\r
+            result = postAuthData(postData.toString());\r
+        }catch(IOException e){\r
+            clearAuthentication();\r
+            throw e;\r
+        }\r
+\r
+        return result;\r
+    }\r
+\r
+    /**\r
+     * ログアウト処理を行う。\r
+     * @throws java.io.IOException ネットワーク入出力エラー\r
+     */\r
+    // TODO シャットダウンフックでログアウトさせようかな…\r
+    public void logout() throws IOException{\r
+        if(!hasLoggedIn()){\r
+            return;\r
+        }\r
+        if(this.encodedUserID == null){\r
+            clearAuthentication();\r
+            return;\r
+        }\r
+\r
+        String redirect = formEncode("&#bottom"); // TODO 必要?\r
+\r
+        StringBuilder postData = new StringBuilder();\r
+        postData.append("cmd=logout");\r
+        postData.append('&').append("cgi_param=").append(redirect);\r
+        postData.append('&').append("user_id=").append(this.encodedUserID);\r
+\r
+        try{\r
+            postAuthData(postData.toString());\r
+        }finally{\r
+            clearAuthentication();\r
+        }\r
+\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * 認証情報クリア。\r
+     */\r
+    // TODO タイマーでExpire date の時刻にクリアしたい。\r
+    protected void clearAuthentication(){\r
+        this.cookieAuth = null;\r
+        this.encodedUserID = null;\r
+        return;\r
+    }\r
+\r
+    /**\r
+     * 認証情報のセット。\r
+     * @param cookie 認証Cookie\r
+     */\r
+    private void setAuthentication(AccountCookie cookie){\r
+        this.cookieAuth = cookie;\r
+        return;\r
+    }\r
+\r
+    // TODO JRE1.6対応するときに HttpCookie, CookieManager 利用へ移行したい。\r
+\r
+}\r