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