Merge branch 'subtree'
[fanfix.git] / src / be / nikiroo / fanfix / library / WebLibraryServer.java
1 package be.nikiroo.fanfix.library;
2
3 import java.io.ByteArrayInputStream;
4 import java.io.IOException;
5 import java.io.InputStream;
6 import java.net.URL;
7 import java.util.ArrayList;
8 import java.util.Arrays;
9 import java.util.HashMap;
10 import java.util.LinkedList;
11 import java.util.List;
12 import java.util.Map;
13
14 import org.json.JSONArray;
15 import org.json.JSONObject;
16
17 import be.nikiroo.fanfix.Instance;
18 import be.nikiroo.fanfix.bundles.Config;
19 import be.nikiroo.fanfix.data.Chapter;
20 import be.nikiroo.fanfix.data.JsonIO;
21 import be.nikiroo.fanfix.data.MetaData;
22 import be.nikiroo.fanfix.data.Paragraph;
23 import be.nikiroo.fanfix.data.Paragraph.ParagraphType;
24 import be.nikiroo.fanfix.data.Story;
25 import be.nikiroo.utils.Image;
26 import be.nikiroo.utils.LoginResult;
27 import be.nikiroo.utils.NanoHTTPD;
28 import be.nikiroo.utils.NanoHTTPD.Response;
29 import be.nikiroo.utils.NanoHTTPD.Response.Status;
30 import be.nikiroo.utils.Progress;
31
32 public class WebLibraryServer extends WebLibraryServerHtml {
33 class WLoginResult extends LoginResult {
34 public WLoginResult(boolean badLogin, boolean badCookie) {
35 super(badLogin, badCookie);
36 }
37
38 public WLoginResult(String who, String key, String subkey, boolean rw,
39 boolean wl, boolean bl) {
40 super(who, key, subkey, (rw ? "|rw" : "") + (wl ? "|wl" : "")
41 + (bl ? "|bl" : "") + "|");
42 }
43
44 public WLoginResult(String cookie, String who, String key,
45 List<String> subkeys) {
46 super(cookie, who, key, subkeys,
47 subkeys == null || subkeys.isEmpty());
48 }
49
50 public boolean isRw() {
51 return getOption().contains("|rw|");
52 }
53
54 public boolean isWl() {
55 return getOption().contains("|wl|");
56 }
57
58 public boolean isBl() {
59 return getOption().contains("|bl|");
60 }
61 }
62
63 private Map<String, Story> storyCache = new HashMap<String, Story>();
64 private LinkedList<String> storyCacheOrder = new LinkedList<String>();
65 private long storyCacheSize = 0;
66 private long maxStoryCacheSize;
67
68 private List<String> whitelist;
69 private List<String> blacklist;
70
71 private Map<String, Progress> imprts = new HashMap<String, Progress>();
72
73 private boolean exiting;
74
75 public WebLibraryServer(boolean secure) throws IOException {
76 super(secure);
77
78 int cacheMb = Instance.getInstance().getConfig()
79 .getInteger(Config.SERVER_MAX_CACHE_MB, 100);
80 maxStoryCacheSize = cacheMb * 1024 * 1024;
81
82 setTraceHandler(Instance.getInstance().getTraceHandler());
83
84 whitelist = Instance.getInstance().getConfig()
85 .getList(Config.SERVER_WHITELIST, new ArrayList<String>());
86 blacklist = Instance.getInstance().getConfig()
87 .getList(Config.SERVER_BLACKLIST, new ArrayList<String>());
88 }
89
90 /**
91 * Start the server (listen on the network for new connections).
92 * <p>
93 * Can only be called once.
94 * <p>
95 * This call is asynchronous, and will just start a new {@link Thread} on
96 * itself (see {@link WebLibraryServer#run()}).
97 */
98 public void start() {
99 new Thread(this).start();
100 }
101
102 @Override
103 protected Response stop(WLoginResult login) {
104 if (!login.isRw()) {
105 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
106 NanoHTTPD.MIME_PLAINTEXT, "Exit not allowed");
107 }
108
109 if (exiting) {
110 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
111 NanoHTTPD.MIME_PLAINTEXT, "Server is already exiting...");
112 }
113
114 exiting = true;
115 Instance.getInstance().getTraceHandler().trace("Exiting");
116
117 boolean ok;
118 do {
119 synchronized (imprts) {
120 ok = imprts.isEmpty();
121 }
122 if (!ok) {
123 try {
124 Thread.sleep(2000);
125 } catch (InterruptedException e) {
126 Instance.getInstance().getTraceHandler()
127 .trace("Waiting to exit...");
128 }
129 }
130 } while (!ok);
131
132 doStop();
133
134 new Thread(new Runnable() {
135 @Override
136 public void run() {
137 try {
138 Thread.sleep(1500);
139 } catch (InterruptedException e) {
140 }
141
142 Instance.getInstance().getTraceHandler()
143 .trace("Exit timeout: force-quit");
144 System.exit(0);
145 }
146 }, "Exit program after timeout of 1500 ms").start();
147
148 return NanoHTTPD.newFixedLengthResponse(Status.OK,
149 NanoHTTPD.MIME_PLAINTEXT, "Exited");
150 }
151
152 @Override
153 protected WLoginResult login(boolean badLogin, boolean badCookie) {
154 return new WLoginResult(false, false);
155 }
156
157 @Override
158 protected WLoginResult login(String who, String cookie) {
159 List<String> subkeys = Instance.getInstance().getConfig()
160 .getList(Config.SERVER_ALLOWED_SUBKEYS);
161 String realKey = Instance.getInstance().getConfig()
162 .getString(Config.SERVER_KEY);
163
164 return new WLoginResult(cookie, who, realKey, subkeys);
165 }
166
167 // allow rw/wl
168 @Override
169 protected WLoginResult login(String who, String key, String subkey) {
170 String realKey = Instance.getInstance().getConfig()
171 .getString(Config.SERVER_KEY, "");
172
173 // I don't like NULLs...
174 key = key == null ? "" : key;
175 subkey = subkey == null ? "" : subkey;
176
177 if (!realKey.equals(key)) {
178 return new WLoginResult(true, false);
179 }
180
181 // defaults are true (as previous versions without the feature)
182 boolean rw = true;
183 boolean wl = true;
184 boolean bl = true;
185
186 rw = Instance.getInstance().getConfig().getBoolean(Config.SERVER_RW,
187 rw);
188
189 List<String> allowed = Instance.getInstance().getConfig().getList(
190 Config.SERVER_ALLOWED_SUBKEYS, new ArrayList<String>());
191
192 if (!allowed.isEmpty()) {
193 if (!allowed.contains(subkey)) {
194 return new WLoginResult(true, false);
195 }
196
197 if ((subkey + "|").contains("|rw|")) {
198 rw = true;
199 }
200 if ((subkey + "|").contains("|wl|")) {
201 wl = false; // |wl| = bypass whitelist
202 }
203 if ((subkey + "|").contains("|bl|")) {
204 bl = false; // |bl| = bypass blacklist
205 }
206 }
207
208 return new WLoginResult(who, key, subkey, rw, wl, bl);
209 }
210
211 @Override
212 protected Response getList(String uri, WLoginResult login)
213 throws IOException {
214 if (WebLibraryUrls.LIST_URL_METADATA.equals(uri)) {
215 List<JSONObject> jsons = new ArrayList<JSONObject>();
216 for (MetaData meta : metas(login)) {
217 jsons.add(JsonIO.toJson(meta));
218 }
219
220 return NanoHTTPD.newFixedLengthResponse(Status.OK,
221 "application/json", new JSONArray(jsons).toString());
222 }
223
224 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
225 NanoHTTPD.MIME_PLAINTEXT, null);
226 }
227
228 // /story/luid/chapter/para <-- text/image
229 // /story/luid/cover <-- image
230 // /story/luid/metadata <-- json
231 // /story/luid/json <-- json, whole chapter (no images)
232 @Override
233 protected Response getStoryPart(String uri, WLoginResult login) {
234 String[] uriParts = uri.split("/");
235 int off = 2;
236
237 if (uriParts.length < off + 2) {
238 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
239 NanoHTTPD.MIME_PLAINTEXT, null);
240 }
241
242 String luid = uriParts[off + 0];
243 String chapterStr = uriParts[off + 1];
244 String imageStr = uriParts.length < off + 3 ? null : uriParts[off + 2];
245
246 // 1-based (0 = desc)
247 int chapter = 0;
248 if (chapterStr != null && !"cover".equals(chapterStr)
249 && !"metadata".equals(chapterStr)
250 && !"json".equals(chapterStr)) {
251 try {
252 chapter = Integer.parseInt(chapterStr);
253 if (chapter < 0) {
254 throw new NumberFormatException();
255 }
256 } catch (NumberFormatException e) {
257 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
258 NanoHTTPD.MIME_PLAINTEXT, "Chapter is not valid");
259 }
260 }
261
262 // 1-based
263 int paragraph = 1;
264 if (imageStr != null) {
265 try {
266 paragraph = Integer.parseInt(imageStr);
267 if (paragraph < 0) {
268 throw new NumberFormatException();
269 }
270 } catch (NumberFormatException e) {
271 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
272 NanoHTTPD.MIME_PLAINTEXT, "Paragraph is not valid");
273 }
274 }
275
276 String mimeType = NanoHTTPD.MIME_PLAINTEXT;
277 InputStream in = null;
278 try {
279 if ("cover".equals(chapterStr)) {
280 Image img = storyCover(luid, login);
281 if (img != null) {
282 in = img.newInputStream();
283 }
284 // TODO: get correct image type
285 mimeType = "image/png";
286 } else if ("metadata".equals(chapterStr)) {
287 MetaData meta = meta(luid, login);
288 JSONObject json = JsonIO.toJson(meta);
289 mimeType = "application/json";
290 in = new ByteArrayInputStream(json.toString().getBytes());
291 } else if ("json".equals(chapterStr)) {
292 Story story = story(luid, login);
293 JSONObject json = JsonIO.toJson(story);
294 mimeType = "application/json";
295 in = new ByteArrayInputStream(json.toString().getBytes());
296 } else {
297 Story story = story(luid, login);
298 if (story != null) {
299 if (chapter == 0) {
300 StringBuilder builder = new StringBuilder();
301 for (Paragraph p : story.getMeta().getResume()) {
302 if (builder.length() == 0) {
303 builder.append("\n");
304 }
305 builder.append(p.getContent());
306 }
307
308 in = new ByteArrayInputStream(
309 builder.toString().getBytes("utf-8"));
310 } else {
311 Paragraph para = story.getChapters().get(chapter - 1)
312 .getParagraphs().get(paragraph - 1);
313 Image img = para.getContentImage();
314 if (para.getType() == ParagraphType.IMAGE) {
315 // TODO: get correct image type
316 mimeType = "image/png";
317 in = img.newInputStream();
318 } else {
319 in = new ByteArrayInputStream(
320 para.getContent().getBytes("utf-8"));
321 }
322 }
323 }
324 }
325 } catch (IndexOutOfBoundsException e) {
326 return NanoHTTPD.newFixedLengthResponse(Status.NOT_FOUND,
327 NanoHTTPD.MIME_PLAINTEXT,
328 "Chapter or paragraph does not exist");
329 } catch (IOException e) {
330 Instance.getInstance().getTraceHandler()
331 .error(new IOException("Cannot get image: " + uri, e));
332 return NanoHTTPD.newFixedLengthResponse(Status.INTERNAL_ERROR,
333 NanoHTTPD.MIME_PLAINTEXT, "Error when processing request");
334 }
335
336 return newInputStreamResponse(mimeType, in);
337 }
338
339 // /story/luid/source
340 // /story/luid/title
341 // /story/luid/author
342 @Override
343 protected Response setStoryPart(String uri, String value,
344 WLoginResult login) throws IOException {
345 String[] uriParts = uri.split("/");
346 int off = 2; // "" and "story"
347
348 if (uriParts.length < off + 2) {
349 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
350 NanoHTTPD.MIME_PLAINTEXT, "Invalid story part request");
351 }
352
353 if (!login.isRw()) {
354 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
355 NanoHTTPD.MIME_PLAINTEXT, "SET story part not allowed");
356 }
357
358 if (exiting) {
359 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
360 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
361 }
362
363 String luid = uriParts[off + 0];
364 String type = uriParts[off + 1];
365
366 if (!Arrays.asList("source", "title", "author").contains(type)) {
367 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
368 NanoHTTPD.MIME_PLAINTEXT,
369 "Invalid SET story part: " + type);
370 }
371
372 if (meta(luid, login) != null) {
373 BasicLibrary lib = Instance.getInstance().getLibrary();
374 if ("source".equals(type)) {
375 lib.changeSource(luid, value, null);
376 } else if ("title".equals(type)) {
377 lib.changeTitle(luid, value, null);
378 } else if ("author".equals(type)) {
379 lib.changeAuthor(luid, value, null);
380 }
381 }
382
383 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
384 }
385
386 @Override
387 protected Response getCover(String uri, WLoginResult login)
388 throws IOException {
389 String[] uriParts = uri.split("/");
390 int startAt = 0;
391 int off = 2; // "" and "cover"
392 for (int i = 0; i < off; i++) {
393 startAt += uriParts[i].length() + "/".length();
394 }
395
396 if (uriParts.length < off + 2) {
397 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
398 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
399 }
400
401 String type = uriParts[off + 0];
402 String id = uri.substring(startAt + type.length() + "/".length());
403
404 InputStream in = null;
405
406 if ("story".equals(type)) {
407 Image img = storyCover(id, login);
408 if (img != null) {
409 in = img.newInputStream();
410 }
411 } else if ("source".equals(type)) {
412 Image img = sourceCover(id, login);
413 if (img != null) {
414 in = img.newInputStream();
415 }
416 } else if ("author".equals(type)) {
417 Image img = authorCover(id, login);
418 if (img != null) {
419 in = img.newInputStream();
420 }
421 } else {
422 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
423 NanoHTTPD.MIME_PLAINTEXT,
424 "Invalid GET cover type: " + type);
425 }
426
427 // TODO: get correct image type
428 return newInputStreamResponse("image/png", in);
429 }
430
431 @Override
432 protected Response setCover(String uri, String luid, WLoginResult login)
433 throws IOException {
434 String[] uriParts = uri.split("/");
435 int off = 2; // "" and "cover"
436 int startAt = 0;
437 for (int i = 0; i < off; i++) {
438 startAt += uriParts[i].length() + "/".length();
439 }
440
441 if (uriParts.length < off + 2) {
442 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
443 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
444 }
445
446 if (!login.isRw()) {
447 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
448 NanoHTTPD.MIME_PLAINTEXT, "Cover request not allowed");
449 }
450
451 if (exiting) {
452 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
453 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
454 }
455
456 String type = uriParts[off + 0];
457 String id = uri.substring(startAt + type.length() + "/".length());
458
459 if ("source".equals(type)) {
460 sourceCover(id, login, luid);
461 } else if ("author".equals(type)) {
462 authorCover(id, login, luid);
463 } else {
464 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
465 NanoHTTPD.MIME_PLAINTEXT,
466 "Invalid SET cover type: " + type);
467 }
468
469 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
470 }
471
472 @Override
473 protected Response imprt(String uri, String urlStr, WLoginResult login)
474 throws IOException {
475 final BasicLibrary lib = Instance.getInstance().getLibrary();
476
477 if (!login.isRw()) {
478 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
479 NanoHTTPD.MIME_PLAINTEXT, "Import not allowed");
480 }
481
482 if (exiting) {
483 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
484 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
485 }
486
487 final URL url = new URL(urlStr);
488 final Progress pg = new Progress();
489 final String luid = lib.getNextId();
490
491 synchronized (imprts) {
492 imprts.put(luid, pg);
493 }
494
495 new Thread(new Runnable() {
496 @Override
497 public void run() {
498 try {
499 lib.imprt(url, luid, pg);
500 } catch (IOException e) {
501 Instance.getInstance().getTraceHandler().error(e);
502 } finally {
503 synchronized (imprts) {
504 imprts.remove(luid);
505 }
506 }
507 }
508 }, "Import story: " + urlStr).start();
509
510 return NanoHTTPD.newFixedLengthResponse(Status.OK,
511 NanoHTTPD.MIME_PLAINTEXT, luid);
512 }
513
514 @Override
515 protected Response imprtProgress(String uri, WLoginResult login) {
516 String[] uriParts = uri.split("/");
517 int off = 2; // "" and "import"
518
519 if (uriParts.length < off + 1) {
520 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
521 NanoHTTPD.MIME_PLAINTEXT, "Invalid cover request");
522 }
523
524 String luid = uriParts[off + 0];
525
526 Progress pg = null;
527 synchronized (imprts) {
528 pg = imprts.get(luid);
529 }
530 if (pg != null) {
531 return NanoHTTPD.newFixedLengthResponse(Status.OK,
532 "application/json", JsonIO.toJson(pg).toString());
533 }
534
535 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
536 }
537
538 @Override
539 protected Response delete(String uri, WLoginResult login)
540 throws IOException {
541 String[] uriParts = uri.split("/");
542 int off = 2; // "" and "delete"
543
544 if (uriParts.length < off + 1) {
545 return NanoHTTPD.newFixedLengthResponse(Status.BAD_REQUEST,
546 NanoHTTPD.MIME_PLAINTEXT, "Invalid delete request");
547 }
548
549 if (!login.isRw()) {
550 return NanoHTTPD.newFixedLengthResponse(Status.FORBIDDEN,
551 NanoHTTPD.MIME_PLAINTEXT, "Delete not allowed");
552 }
553
554 if (exiting) {
555 return NanoHTTPD.newFixedLengthResponse(Status.SERVICE_UNAVAILABLE,
556 NanoHTTPD.MIME_PLAINTEXT, "Server is exiting...");
557 }
558
559 String luid = uriParts[off + 0];
560
561 BasicLibrary lib = Instance.getInstance().getLibrary();
562 lib.delete(luid);
563
564 return newInputStreamResponse(NanoHTTPD.MIME_PLAINTEXT, null);
565 }
566
567 @Override
568 protected List<MetaData> metas(WLoginResult login) throws IOException {
569 BasicLibrary lib = Instance.getInstance().getLibrary();
570 List<MetaData> metas = new ArrayList<MetaData>();
571 for (MetaData meta : lib.getList().getMetas()) {
572 if (isAllowed(meta, login)) {
573 metas.add(meta);
574 }
575 }
576
577 return metas;
578 }
579
580 // NULL if not whitelist OK or if not found
581 @Override
582 protected Story story(String luid, WLoginResult login) throws IOException {
583 synchronized (storyCache) {
584 if (storyCache.containsKey(luid)) {
585 Story story = storyCache.get(luid);
586 if (!isAllowed(story.getMeta(), login))
587 return null;
588
589 return story;
590 }
591 }
592
593 Story story = null;
594 MetaData meta = meta(luid, login);
595 if (meta != null) {
596 BasicLibrary lib = Instance.getInstance().getLibrary();
597 story = lib.getStory(luid, null);
598 long size = sizeOf(story);
599
600 synchronized (storyCache) {
601 // Could have been added by another request
602 if (!storyCache.containsKey(luid)) {
603 while (!storyCacheOrder.isEmpty()
604 && storyCacheSize + size > maxStoryCacheSize) {
605 String oldestLuid = storyCacheOrder.removeFirst();
606 Story oldestStory = storyCache.remove(oldestLuid);
607 maxStoryCacheSize -= sizeOf(oldestStory);
608 }
609
610 storyCacheOrder.add(luid);
611 storyCache.put(luid, story);
612 }
613 }
614 }
615
616 return story;
617 }
618
619 private MetaData meta(String luid, WLoginResult login) throws IOException {
620 BasicLibrary lib = Instance.getInstance().getLibrary();
621 MetaData meta = lib.getInfo(luid);
622 if (!isAllowed(meta, login))
623 return null;
624
625 return meta;
626 }
627
628 private Image storyCover(String luid, WLoginResult login)
629 throws IOException {
630 MetaData meta = meta(luid, login);
631 if (meta != null) {
632 BasicLibrary lib = Instance.getInstance().getLibrary();
633 return lib.getCover(meta.getLuid());
634 }
635
636 return null;
637 }
638
639 private Image authorCover(String author, WLoginResult login)
640 throws IOException {
641 Image img = null;
642
643 List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
644 author, null);
645 if (metas.size() > 0) {
646 BasicLibrary lib = Instance.getInstance().getLibrary();
647 img = lib.getCustomAuthorCover(author);
648 if (img == null)
649 img = lib.getCover(metas.get(0).getLuid());
650 }
651
652 return img;
653
654 }
655
656 private void authorCover(String author, WLoginResult login, String luid)
657 throws IOException {
658 if (meta(luid, login) != null) {
659 List<MetaData> metas = new MetaResultList(metas(login)).filter(null,
660 author, null);
661 if (metas.size() > 0) {
662 BasicLibrary lib = Instance.getInstance().getLibrary();
663 lib.setAuthorCover(author, luid);
664 }
665 }
666 }
667
668 private Image sourceCover(String source, WLoginResult login)
669 throws IOException {
670 Image img = null;
671
672 List<MetaData> metas = new MetaResultList(metas(login)).filter(source,
673 null, null);
674 if (metas.size() > 0) {
675 BasicLibrary lib = Instance.getInstance().getLibrary();
676 img = lib.getCustomSourceCover(source);
677 if (img == null)
678 img = lib.getCover(metas.get(0).getLuid());
679 }
680
681 return img;
682 }
683
684 private void sourceCover(String source, WLoginResult login, String luid)
685 throws IOException {
686 if (meta(luid, login) != null) {
687 List<MetaData> metas = new MetaResultList(metas(login))
688 .filter(source, null, null);
689 if (metas.size() > 0) {
690 BasicLibrary lib = Instance.getInstance().getLibrary();
691 lib.setSourceCover(source, luid);
692 }
693 }
694 }
695
696 private boolean isAllowed(MetaData meta, WLoginResult login) {
697 MetaResultList one = new MetaResultList(Arrays.asList(meta));
698 if (login.isWl() && !whitelist.isEmpty()) {
699 if (one.filter(whitelist, null, null).isEmpty()) {
700 return false;
701 }
702 }
703 if (login.isBl() && !blacklist.isEmpty()) {
704 if (!one.filter(blacklist, null, null).isEmpty()) {
705 return false;
706 }
707 }
708
709 return true;
710 }
711
712 private long sizeOf(Story story) {
713 long size = 0;
714 for (Chapter chap : story) {
715 for (Paragraph para : chap) {
716 if (para.getType() == ParagraphType.IMAGE) {
717 size += para.getContentImage().getSize();
718 } else {
719 size += para.getContent().length();
720 }
721 }
722 }
723
724 return size;
725 }
726
727 public static void main(String[] args) throws IOException {
728 Instance.init();
729 WebLibraryServer web = new WebLibraryServer(false);
730 web.run();
731 }
732 }