OSDN Git Service

スタートアップ処理の改善
[jindolf/Jindolf.git] / src / main / java / jp / sfjp / jindolf / net / ServerAccess.java
1 /*
2  * manage HTTP access
3  *
4  * License : The MIT License
5  * Copyright(c) 2008 olyutorskii
6  */
7
8 package jp.sfjp.jindolf.net;
9
10 import java.awt.image.BufferedImage;
11 import java.io.IOException;
12 import java.io.InputStream;
13 import java.io.OutputStream;
14 import java.io.UnsupportedEncodingException;
15 import java.lang.ref.SoftReference;
16 import java.net.HttpURLConnection;
17 import java.net.MalformedURLException;
18 import java.net.Proxy;
19 import java.net.URL;
20 import java.net.URLEncoder;
21 import java.nio.charset.Charset;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.Map;
25 import java.util.logging.Logger;
26 import javax.imageio.ImageIO;
27 import jp.sfjp.jindolf.data.Period;
28 import jp.sfjp.jindolf.data.Village;
29 import jp.sourceforge.jindolf.parser.ContentBuilder;
30 import jp.sourceforge.jindolf.parser.ContentBuilderSJ;
31 import jp.sourceforge.jindolf.parser.ContentBuilderUCS2;
32 import jp.sourceforge.jindolf.parser.DecodeException;
33 import jp.sourceforge.jindolf.parser.DecodedContent;
34 import jp.sourceforge.jindolf.parser.SjisDecoder;
35 import jp.sourceforge.jindolf.parser.StreamDecoder;
36
37 /**
38  * 国ごとの人狼BBSサーバとの通信を一手に引き受ける。
39  */
40 public class ServerAccess{
41
42     private static final String USER_AGENT = HttpUtils.getUserAgentName();
43     private static final String JINRO_CGI = "./index.rb";
44     private static final
45             Map<String, SoftReference<BufferedImage>> IMAGE_CACHE;
46
47     private static final Logger LOGGER = Logger.getAnonymousLogger();
48
49     static{
50         Map<String, SoftReference<BufferedImage>> cache =
51                 new HashMap<String, SoftReference<BufferedImage>>();
52         IMAGE_CACHE = Collections.synchronizedMap(cache);
53     }
54
55
56     private final URL baseURL;
57     private final Charset charset;
58     private Proxy proxy = Proxy.NO_PROXY;
59     private long lastServerMs;
60     private long lastLocalMs;
61     private long lastSystemMs;
62     private AccountCookie cookieAuth = null;
63     private String encodedUserID = null;
64
65
66     /**
67      * 人狼BBSサーバとの接続管理を生成する。
68      * この時点ではまだ通信は行われない。
69      * @param baseURL 国別のベースURL
70      * @param charset 国のCharset
71      */
72     public ServerAccess(URL baseURL, Charset charset){
73         this.baseURL = baseURL;
74         this.charset = charset;
75         return;
76     }
77
78
79     /**
80      * 画像キャッシュを検索する。
81      * @param key キー
82      * @return キャッシュされた画像。キャッシュされていなければnull。
83      */
84     private static BufferedImage getImageCache(String key){
85         if(key == null) return null;
86
87         BufferedImage image;
88
89         synchronized(IMAGE_CACHE){
90             SoftReference<BufferedImage> ref = IMAGE_CACHE.get(key);
91             if(ref == null) return null;
92
93             Object referent = ref.get();
94             if(referent == null){
95                 IMAGE_CACHE.remove(key);
96                 return null;
97             }
98
99             image = (BufferedImage) referent;
100         }
101
102         return image;
103     }
104
105     /**
106      * 画像キャッシュに登録する。
107      * @param key キー
108      * @param image キャッシュしたい画像。
109      */
110     private static void putImageCache(String key, BufferedImage image){
111         if(key == null || image == null) return;
112
113         synchronized(IMAGE_CACHE){
114             if(getImageCache(key) != null) return;
115             SoftReference<BufferedImage> ref =
116                     new SoftReference<BufferedImage>(image);
117             IMAGE_CACHE.put(key, ref);
118         }
119
120         return;
121     }
122
123     /**
124      * 与えられた文字列に対し「application/x-www-form-urlencoded」符号化を行う。
125      * この符号化はHTTPのPOSTメソッドで必要になる。
126      * この処理は、一般的なPC用Webブラウザにおける、
127      * Shift_JISで書かれたHTML文書のFORMタグに伴う
128      * submit処理を模倣する。
129      * @param formData 元の文字列
130      * @return 符号化された文字列
131      */
132     public static String formEncode(String formData){
133         if(formData == null){
134             return null;
135         }
136         String result;
137         try{
138             result = URLEncoder.encode(formData, "US-ASCII");
139         }catch(UnsupportedEncodingException e){
140             assert false;
141             result = null;
142         }
143         return result;
144     }
145
146     /**
147      * 配列版formEncode。
148      * @param formData 元の文字列
149      * @return 符号化された文字列
150      */
151     public static String formEncode(char[] formData){
152         return formEncode(new String(formData));
153     }
154
155     /**
156      * HTTP-Proxyを返す。
157      * @return HTTP-Proxy
158      */
159     public Proxy getProxy(){
160         return this.proxy;
161     }
162
163     /**
164      * HTTP-Proxyを設定する。
165      * @param proxy HTTP-Proxy。nullならProxyなしと解釈される。
166      */
167     public void setProxy(Proxy proxy){
168         if(proxy == null) this.proxy = Proxy.NO_PROXY;
169         else              this.proxy = proxy;
170         return;
171     }
172
173     /**
174      * 国のベースURLを返す。
175      * @return ベースURL
176      */
177     public URL getBaseURL(){
178         return this.baseURL;
179     }
180
181     /**
182      * 与えられたクエリーとCGIのURLから新たにURLを合成する。
183      * @param query クエリー
184      * @return 新たなURL
185      */
186     protected URL getQueryURL(String query){
187         if(query.length() >= 1 && query.charAt(0) != '?'){
188             return null;
189         }
190
191         URL result;
192         try{
193             result = new URL(getBaseURL(), JINRO_CGI + query);
194         }catch(MalformedURLException e){
195             assert false;
196             return null;
197         }
198         return result;
199     }
200
201     /**
202      * 「Shift_JIS」でエンコーディングされた入力ストリームから文字列を生成する。
203      * @param istream 入力ストリーム
204      * @return 文字列
205      * @throws java.io.IOException 入出力エラー(おそらくネットワーク関連)
206      */
207     public DecodedContent downloadHTMLStream(InputStream istream)
208             throws IOException{
209         StreamDecoder decoder;
210         ContentBuilder builder;
211         if(this.charset.name().equalsIgnoreCase("Shift_JIS")){
212             decoder = new SjisDecoder();
213             builder = new ContentBuilderSJ(200 * 1024);
214         }else if(this.charset.name().equalsIgnoreCase("UTF-8")){
215             decoder = new StreamDecoder(this.charset.newDecoder());
216             builder = new ContentBuilderUCS2(200 * 1024);
217         }else{
218             assert false;
219             return null;
220         }
221         decoder.setDecodeHandler(builder);
222
223         // TODO デコーダをインスタンス変数にできないか。
224         // TODO DecodedContentのキャッシュ管理。
225
226         try{
227             decoder.decode(istream);
228         }catch(DecodeException e){
229             return null;
230         }
231
232         return builder.getContent();
233     }
234
235     /**
236      * 与えられたクエリーを用いてHTMLデータを取得する。
237      * @param query HTTP-GET クエリー
238      * @return HTMLデータ
239      * @throws java.io.IOException ネットワークエラー
240      */
241     protected HtmlSequence downloadHTML(String query)
242             throws IOException{
243         URL url = getQueryURL(query);
244         HtmlSequence result = downloadHTML(url);
245         return result;
246     }
247
248     /**
249      * 与えられたURLを用いてHTMLデータを取得する。
250      * @param url URL
251      * @return HTMLデータ
252      * @throws java.io.IOException ネットワークエラー
253      */
254     protected HtmlSequence downloadHTML(URL url)
255             throws IOException{
256         HttpURLConnection connection =
257                 (HttpURLConnection) url.openConnection(this.proxy);
258         connection.setRequestProperty("Accept", "*/*");
259         connection.setRequestProperty("User-Agent", USER_AGENT);
260         connection.setUseCaches(false);
261         connection.setInstanceFollowRedirects(false);
262         connection.setDoInput(true);
263         connection.setRequestMethod("GET");
264
265         AccountCookie cookie = this.cookieAuth;
266         if(cookie != null){
267             if(shouldAccept(url, cookie)){
268                 connection.setRequestProperty(
269                         "Cookie",
270                         "login=" + cookie.getLoginData());
271             }else{
272                 clearAuthentication();
273             }
274         }
275
276         connection.connect();
277
278         long datems = updateLastAccess(connection);
279
280         int responseCode = connection.getResponseCode();
281         if(responseCode != HttpURLConnection.HTTP_OK){ // 200
282             String logMessage =  "発言のダウンロードに失敗しました。";
283             logMessage += HttpUtils.formatHttpStat(connection, 0, 0);
284             LOGGER.warning(logMessage);
285             return null;
286         }
287
288         String cs = HttpUtils.getHTMLCharset(connection);
289         if(!cs.equalsIgnoreCase(this.charset.name())){
290             return null;
291         }
292
293         InputStream stream = TallyInputStream.getInputStream(connection);
294         DecodedContent html = downloadHTMLStream(stream);
295
296         stream.close();
297         connection.disconnect();
298
299         HtmlSequence hseq = new HtmlSequence(url, datems, html);
300
301         return hseq;
302     }
303
304     /**
305      * 絶対または相対URLの指すパーマネントなイメージ画像をダウンロードする。
306      * @param url 画像URL文字列
307      * @return 画像イメージ
308      * @throws java.io.IOException ネットワークエラー
309      */
310     public BufferedImage downloadImage(String url) throws IOException{
311         URL absolute;
312         try{
313             URL base = getBaseURL();
314             absolute = new URL(base, url);
315         }catch(MalformedURLException e){
316             assert false;
317             return null;
318         }
319
320         BufferedImage image;
321         image = getImageCache(absolute.toString());
322         if(image != null) return image;
323
324         HttpURLConnection connection =
325                 (HttpURLConnection) absolute.openConnection(this.proxy);
326         connection.setRequestProperty("Accept", "*/*");
327         connection.setRequestProperty("User-Agent", USER_AGENT);
328         connection.setUseCaches(true);
329         connection.setInstanceFollowRedirects(true);
330         connection.setDoInput(true);
331         connection.setRequestMethod("GET");
332
333         connection.connect();
334
335         int responseCode       = connection.getResponseCode();
336         if(responseCode != HttpURLConnection.HTTP_OK){
337             String logMessage =  "イメージのダウンロードに失敗しました。";
338             logMessage += HttpUtils.formatHttpStat(connection, 0, 0);
339             LOGGER.warning(logMessage);
340             return null;
341         }
342
343         InputStream stream = TallyInputStream.getInputStream(connection);
344         image = ImageIO.read(stream);
345         stream.close();
346
347         connection.disconnect();
348
349         putImageCache(absolute.toString(), image);
350
351         return image;
352     }
353
354     /**
355      * 指定された認証情報をPOSTする。
356      * @param authData 認証情報
357      * @return 認証情報が受け入れられたらtrue
358      * @throws java.io.IOException ネットワークエラー
359      */
360     protected boolean postAuthData(String authData) throws IOException{
361         URL url = getQueryURL("");
362         HttpURLConnection connection =
363                 (HttpURLConnection) url.openConnection(this.proxy);
364         connection.setRequestProperty("Accept", "*/*");
365         connection.setRequestProperty("User-Agent", USER_AGENT);
366         connection.setUseCaches(false);
367         connection.setInstanceFollowRedirects(false);
368         connection.setDoInput(true);
369         connection.setDoOutput(true);
370         connection.setRequestMethod("POST");
371
372         byte[] authBytes = authData.getBytes();
373
374         OutputStream os = TallyOutputStream.getOutputStream(connection);
375         os.write(authBytes);
376         os.flush();
377         os.close();
378
379         updateLastAccess(connection);
380
381         int responseCode = connection.getResponseCode();
382         if(responseCode != HttpURLConnection.HTTP_MOVED_TEMP){    // 302
383             String logMessage =  "認証情報の送信に失敗しました。";
384             LOGGER.warning(logMessage);
385             connection.disconnect();
386             return false;
387         }
388
389         connection.disconnect();
390
391         AccountCookie loginCookie = AccountCookie.createCookie(connection);
392         if(loginCookie == null){
393             return false;
394         }
395
396         setAuthentication(loginCookie);
397
398         LOGGER.info("正しく認証が行われました。");
399
400         return true;
401     }
402
403     /**
404      * トップページのHTMLデータを取得する。
405      * @return HTMLデータ
406      * @throws java.io.IOException ネットワークエラー
407      */
408     public HtmlSequence getHTMLTopPage() throws IOException{
409         return downloadHTML("");
410     }
411
412     /**
413      * 国に含まれる村一覧HTMLデータを取得する。
414      * @return HTMLデータ
415      * @throws java.io.IOException ネットワークエラー
416      */
417     public HtmlSequence getHTMLLandList() throws IOException{
418         return downloadHTML("?cmd=log");
419     }
420
421     /**
422      * 指定された村のPeriod一覧のHTMLデータを取得する。
423      * 現在ゲーム進行中の村にも可能。
424      * ※ 古国では使えないよ!
425      * @param village 村
426      * @return HTMLデータ
427      * @throws java.io.IOException ネットワークエラー
428      */
429     public HtmlSequence getHTMLBoneHead(Village village) throws IOException{
430         String villageID = village.getVillageID();
431         return downloadHTML("?vid=" + villageID + "&meslog=");
432     }
433
434     /**
435      * 指定された村の最新PeriodのHTMLデータをロードする。
436      * 既にGAMEOVERの村ではPeriod一覧のHTMLデータとなる。
437      * @param village 村
438      * @return HTMLデータ
439      * @throws java.io.IOException ネットワークエラー
440      */
441     public HtmlSequence getHTMLVillage(Village village) throws IOException{
442         URL url = getVillageURL(village);
443         return downloadHTML(url);
444     }
445
446     /**
447      * 指定された村の最新PeriodのHTMLデータのURLを取得する。
448      * @param village 村
449      * @return URL
450      */
451     public URL getVillageURL(Village village){
452         String villageID = village.getVillageID();
453         URL url = getQueryURL("?vid=" + villageID);
454         return url;
455     }
456
457     /**
458      * 指定されたPeriodのHTMLデータをロードする。
459      * @param period Period
460      * @return HTMLデータ
461      * @throws java.io.IOException ネットワークエラー
462      */
463     public HtmlSequence getHTMLPeriod(Period period) throws IOException{
464         URL url = getPeriodURL(period);
465         return downloadHTML(url);
466     }
467
468     /**
469      * 指定されたPeriodのHTMLデータのURLを取得する。
470      * @param period 日
471      * @return URL
472      */
473     public URL getPeriodURL(Period period){
474         String query = period.getCGIQuery();
475         URL url = getQueryURL(query);
476         return url;
477     }
478
479     /**
480      * 最終アクセス時刻を更新する。
481      * @param connection HTTP接続
482      * @return リソース送信時刻
483      */
484     public long updateLastAccess(HttpURLConnection connection){
485         this.lastServerMs = connection.getDate();
486         this.lastLocalMs = System.currentTimeMillis();
487         this.lastSystemMs = System.nanoTime() / (1000 * 1000);
488         return this.lastServerMs;
489     }
490
491     /**
492      * 指定したURLに対しCookieを送っても良いか否か判定する。
493      * 判別材料は Cookie の寿命とパス指定のみ。
494      * @param url URL
495      * @param cookie Cookie
496      * @return 送ってもよければtrue
497      */
498     private static boolean shouldAccept(URL url, AccountCookie cookie){
499         if(cookie.hasExpired()){
500             return false;
501         }
502
503         String urlPath = url.getPath();
504         String cookiePath = cookie.getPathURI().getPath();
505
506         if( ! urlPath.startsWith(cookiePath) ){
507             return false;
508         }
509
510         return true;
511     }
512
513     /**
514      * 現在ログイン中か否か判別する。
515      * @return ログイン中ならtrue
516      */
517     // TODO interval call
518     public boolean hasLoggedIn(){
519         AccountCookie cookie = this.cookieAuth;
520         if(cookie == null){
521             return false;
522         }
523         if(cookie.hasExpired()){
524             clearAuthentication();
525             return false;
526         }
527         return true;
528     }
529
530     /**
531      * 与えられたユーザIDとパスワードでログイン処理を行う。
532      * @param userID ユーザID
533      * @param password パスワード
534      * @return ログインに成功すればtrue
535      * @throws java.io.IOException ネットワークエラー
536      */
537     public final boolean login(String userID, char[] password)
538             throws IOException{
539         if(hasLoggedIn()){
540             return true;
541         }
542
543         String id = formEncode(userID);
544         if(id == null || id.length() <= 0){
545             return false;
546         }
547
548         String pw = formEncode(password);
549         if(pw == null || pw.length() <= 0){
550             return false;
551         }
552
553         this.encodedUserID = id;
554
555         String redirect = formEncode("&#bottom");   // TODO ほんとに必要?
556
557         StringBuilder postData = new StringBuilder();
558         postData.append("cmd=login");
559         postData.append('&').append("cgi_param=").append(redirect);
560         postData.append('&').append("user_id=").append(id);
561         postData.append('&').append("password=").append(pw);
562
563         boolean result;
564         try{
565             result = postAuthData(postData.toString());
566         }catch(IOException e){
567             clearAuthentication();
568             throw e;
569         }
570
571         return result;
572     }
573
574     /**
575      * ログアウト処理を行う。
576      * @throws java.io.IOException ネットワーク入出力エラー
577      */
578     // TODO シャットダウンフックでログアウトさせようかな…
579     public void logout() throws IOException{
580         if(!hasLoggedIn()){
581             return;
582         }
583         if(this.encodedUserID == null){
584             clearAuthentication();
585             return;
586         }
587
588         String redirect = formEncode("&#bottom"); // TODO 必要?
589
590         StringBuilder postData = new StringBuilder();
591         postData.append("cmd=logout");
592         postData.append('&').append("cgi_param=").append(redirect);
593         postData.append('&').append("user_id=").append(this.encodedUserID);
594
595         try{
596             postAuthData(postData.toString());
597         }finally{
598             clearAuthentication();
599         }
600
601         return;
602     }
603
604     /**
605      * 認証情報クリア。
606      */
607     // TODO タイマーでExpire date の時刻にクリアしたい。
608     protected void clearAuthentication(){
609         this.cookieAuth = null;
610         this.encodedUserID = null;
611         return;
612     }
613
614     /**
615      * 認証情報のセット。
616      * @param cookie 認証Cookie
617      */
618     private void setAuthentication(AccountCookie cookie){
619         this.cookieAuth = cookie;
620         return;
621     }
622
623     // TODO JRE1.6対応するときに HttpCookie, CookieManager 利用へ移行したい。
624
625 }