Fix some bugs in remote/sync (still not complete)
[jvcard.git] / src / be / nikiroo / jvcard / remote / Server.java
1 package be.nikiroo.jvcard.remote;
2
3 import java.io.File;
4 import java.io.IOException;
5 import java.net.ServerSocket;
6 import java.net.Socket;
7 import java.net.UnknownHostException;
8 import java.security.InvalidParameterException;
9 import java.util.Date;
10 import java.util.HashMap;
11 import java.util.LinkedList;
12 import java.util.List;
13 import java.util.Map;
14 import java.util.ResourceBundle;
15
16 import be.nikiroo.jvcard.Card;
17 import be.nikiroo.jvcard.Contact;
18 import be.nikiroo.jvcard.Data;
19 import be.nikiroo.jvcard.parsers.Format;
20 import be.nikiroo.jvcard.parsers.Vcard21Parser;
21 import be.nikiroo.jvcard.remote.Command.Verb;
22 import be.nikiroo.jvcard.resources.Bundles;
23 import be.nikiroo.jvcard.resources.StringUtils;
24
25 /**
26 * This class implements a small server that can listen for requests to
27 * synchronise, get and put {@link Card}s.
28 *
29 * <p>
30 * It is <b>NOT</b> secured in any way (it even is nice enough to give you a
31 * help message when you connect in raw mode via nc on how to use it), so do
32 * <b>NOT</b> enable such a server to be accessible from internet. This is not
33 * safe. Use a ssh/openssl tunnel or similar.
34 * </p>
35 *
36 * @author niki
37 *
38 */
39 public class Server implements Runnable {
40 private ServerSocket ss;
41 private int port;
42 private boolean stop;
43 private File dataDir;
44
45 private Object clientsLock = new Object();
46 private List<SimpleSocket> clients = new LinkedList<SimpleSocket>();
47
48 private Object updateLock = new Object();
49 private Map<File, Integer> updates = new HashMap<File, Integer>();
50
51 /**
52 * Create a new jVCard server on the given port.
53 *
54 * @param port
55 * the port to run on
56 *
57 * @throws IOException
58 * in case of IO error
59 */
60 public Server(int port) throws IOException {
61 this.port = port;
62 ResourceBundle bundle = Bundles.getBundle("remote");
63 try {
64 String dir = bundle.getString("SERVER_DATA_PATH");
65 dataDir = new File(dir);
66 dataDir.mkdir();
67
68 if (!dataDir.exists()) {
69 throw new IOException("Cannot open or create data store at: "
70 + dataDir);
71 }
72 } catch (Exception e) {
73 e.printStackTrace();
74 throw new IOException("Cannot open or create data store at: "
75 + dataDir, e);
76 }
77
78 ss = new ServerSocket(port);
79 }
80
81 /**
82 * Stop the server. It may take some time before returning, but will only
83 * return when the server is actually stopped.
84 */
85 public void stop() {
86 stop = true;
87 try {
88 SimpleSocket c = new SimpleSocket(new Socket((String) null, port),
89 "special STOP client");
90 c.open(true);
91 c.sendCommand(Verb.STOP);
92 c.close();
93 } catch (UnknownHostException e) {
94 e.printStackTrace();
95 } catch (IOException e) {
96 e.printStackTrace();
97 }
98
99 if (clients.size() > 0) {
100 try {
101 Thread.sleep(100);
102 } catch (InterruptedException e) {
103 }
104
105 if (clients.size() > 0) {
106 synchronized (clientsLock) {
107 for (SimpleSocket s : clients) {
108 System.err
109 .println("Forcefully closing client connection");
110 s.close();
111 }
112
113 clients.clear();
114 }
115 }
116 }
117 }
118
119 @Override
120 public void run() {
121 while (!stop) {
122 try {
123 final Socket s = ss.accept();
124 // TODO: thread pool?
125 new Thread(new Runnable() {
126 @Override
127 public void run() {
128 SimpleSocket ss = new SimpleSocket(s, "[request]");
129
130 addClient(ss);
131 try {
132 ss.open(false);
133
134 while (processCmd(ss))
135 ;
136
137 } catch (IOException e) {
138 e.printStackTrace();
139 } finally {
140 ss.close();
141 }
142 removeClient(ss);
143 }
144 }).start();
145 } catch (IOException ioe) {
146 ioe.printStackTrace();
147 }
148 }
149 }
150
151 /**
152 * Add a client to the current count.
153 *
154 * @return the client index number
155 */
156 private void addClient(SimpleSocket s) {
157 synchronized (clientsLock) {
158 clients.add(s);
159 }
160 }
161
162 /**
163 * Remove a client from the current count.
164 *
165 * @param index
166 * the client index number
167 */
168 private void removeClient(SimpleSocket s) {
169 synchronized (clientsLock) {
170 clients.remove(s);
171 }
172 }
173
174 /**
175 * Process a first-level command.
176 *
177 * @param s
178 * the {@link SimpleSocket} from which to get the command to
179 * process
180 *
181 * @return TRUE if the client is ready for another command, FALSE when the
182 * client exited
183 *
184 * @throws IOException
185 * in case of IO error
186 */
187 private boolean processCmd(SimpleSocket s) throws IOException {
188 Command cmd = s.receiveCommand();
189 Command.Verb verb = cmd.getVerb();
190
191 if (verb == null)
192 return false;
193
194 boolean clientContinue = true;
195
196 System.out.println(s + " -> " + verb
197 + (cmd.getParam() == null ? "" : " " + cmd.getParam()));
198
199 switch (verb) {
200 case STOP: {
201 clientContinue = false;
202 break;
203 }
204 case VERSION: {
205 s.sendCommand(Verb.VERSION);
206 break;
207 }
208 case TIME: {
209 s.sendLine(StringUtils.fromTime(new Date().getTime()));
210 break;
211 }
212 case SELECT: {
213 String name = cmd.getParam();
214 File file = new File(dataDir.getAbsolutePath() + File.separator
215 + name);
216 if (name == null || name.length() == 0 || !file.exists()) {
217 System.err
218 .println("SELECT: resource not found, closing connection: "
219 + name);
220 clientContinue = false;
221 } else {
222 synchronized (updateLock) {
223 for (File f : updates.keySet()) {
224 if (f.getCanonicalPath()
225 .equals(file.getCanonicalPath())) {
226 file = f;
227 break;
228 }
229 }
230
231 if (!updates.containsKey(file))
232 updates.put(file, 0);
233 updates.put(file, updates.get(file) + 1);
234 }
235
236 synchronized (file) {
237 try {
238 s.sendLine(StringUtils.fromTime(file.lastModified()));
239
240 while (processLockedCmd(s, name))
241 ;
242 } catch (InvalidParameterException e) {
243 System.err
244 .println("Unsupported command received from a client connection, closing it: "
245 + verb + " (" + e.getMessage() + ")");
246 clientContinue = false;
247 }
248 }
249
250 synchronized (updateLock) {
251 int num = updates.get(file) - 1;
252 if (num == 0) {
253 updates.remove(file);
254 } else {
255 updates.put(file, num);
256 }
257 }
258 }
259 break;
260 }
261 case LIST: {
262 for (File file : dataDir.listFiles()) {
263 if (cmd.getParam() == null || cmd.getParam().length() == 0
264 || file.getName().contains(cmd.getParam())) {
265 s.send(StringUtils.fromTime(file.lastModified()) + " "
266 + file.getName());
267 }
268 }
269 s.sendBlock();
270 break;
271 }
272 case HELP: {
273 // TODO: i18n
274 s.send("The following commands are available:");
275 s.send("- TIME: get the server time");
276 s.send("- HELP: this help screen");
277 s.send("- LIST: list the available cards on this server");
278 s.send("- VERSION/GET/PUT/POST/DELETE/STOP: TODO");
279 s.sendBlock();
280 break;
281 }
282 default: {
283 System.err
284 .println("Unsupported command received from a client connection, closing it: "
285 + verb);
286 clientContinue = false;
287 break;
288 }
289 }
290
291 return clientContinue;
292 }
293
294 /**
295 * Process a subcommand while protected for resource <tt>name</tt>.
296 *
297 * @param s
298 * the {@link SimpleSocket} to process
299 *
300 * @param name
301 * the resource that is protected (and to target)
302 *
303 * @return TRUE if the client is ready for another command, FALSE when the
304 * client is done
305 *
306 * @throws IOException
307 * in case of IO error
308 *
309 * @throw InvalidParameterException in case of invalid subcommand
310 */
311 private boolean processLockedCmd(SimpleSocket s, String name)
312 throws IOException {
313 Command cmd = s.receiveCommand();
314 Command.Verb verb = cmd.getVerb();
315
316 if (verb == null)
317 return false;
318
319 boolean clientContinue = true;
320
321 System.out.println(s + " -> " + verb);
322
323 switch (verb) {
324 case GET_CARD: {
325 s.sendBlock(doGetCard(name));
326 break;
327 }
328 case POST_CARD: {
329 s.sendLine(doPostCard(name, s.receiveBlock()));
330 break;
331 }
332 case PUT_CARD: {
333 File vcf = getFile(name);
334 if (vcf == null) {
335 System.err
336 .println("Fail to update a card, file not available: "
337 + name);
338 clientContinue = false;
339 } else {
340 Card card = new Card(vcf, Format.VCard21);
341 try {
342 while (processContactCmd(s, card))
343 ;
344 card.save();
345 } catch (InvalidParameterException e) {
346 System.err
347 .println("Unsupported command received from a client connection, closing it: "
348 + verb + " (" + e.getMessage() + ")");
349 clientContinue = false;
350 }
351 }
352 break;
353 }
354 case DELETE_CARD: {
355 // TODO
356 System.err
357 .println("Unsupported command received from a client connection, closing it: "
358 + verb);
359 clientContinue = false;
360 break;
361 }
362 case SELECT: {
363 clientContinue = false;
364 break;
365 }
366 default: {
367 throw new InvalidParameterException("command invalid here");
368 }
369 }
370
371 return clientContinue;
372 }
373
374 /**
375 * Process a *_CONTACT subcommand.
376 *
377 * @param s
378 * the {@link SimpleSocket} to process
379 * @param card
380 * the target {@link Card}
381 *
382 * @return TRUE if the client is ready for another command, FALSE when the
383 * client is done
384 *
385 * @throws IOException
386 * in case of IO error
387 *
388 * @throw InvalidParameterException in case of invalid subcommand
389 */
390 private boolean processContactCmd(SimpleSocket s, Card card)
391 throws IOException {
392 Command cmd = s.receiveCommand();
393 Command.Verb verb = cmd.getVerb();
394
395 if (verb == null)
396 return false;
397
398 boolean clientContinue = true;
399
400 System.out.println(s + " -> " + verb);
401
402 switch (verb) {
403 case GET_CONTACT: {
404 Contact contact = card.getById(cmd.getParam());
405 if (contact != null)
406 s.sendBlock(Vcard21Parser.toStrings(contact, -1));
407 else
408 s.sendBlock();
409 break;
410 }
411 case POST_CONTACT: {
412 String uid = cmd.getParam();
413 Contact contact = card.getById(uid);
414 if (contact != null)
415 contact.delete();
416 List<Contact> list = Vcard21Parser.parseContact(s.receiveBlock());
417 if (list.size() > 0) {
418 contact = list.get(0);
419 contact.getPreferredData("UID").setValue(uid);
420 card.add(contact);
421 }
422 break;
423 }
424 case PUT_CONTACT: {
425 String uid = cmd.getParam();
426 Contact contact = card.getById(uid);
427 if (contact == null) {
428 throw new InvalidParameterException(
429 "Cannot find contact to modify for UID: " + uid);
430 }
431 while (processDataCmd(s, contact))
432 ;
433 break;
434 }
435 case DELETE_CONTACT: {
436 String uid = cmd.getParam();
437 Contact contact = card.getById(uid);
438 if (contact == null) {
439 throw new InvalidParameterException(
440 "Cannot find contact to delete for UID: " + uid);
441 }
442
443 contact.delete();
444 break;
445 }
446 case PUT_CARD: {
447 clientContinue = false;
448 break;
449 }
450 default: {
451 throw new InvalidParameterException("command invalid here");
452 }
453 }
454
455 return clientContinue;
456 }
457
458 /**
459 * Process a *_DATA subcommand.
460 *
461 * @param s
462 * the {@link SimpleSocket} to process
463 * @param card
464 * the target {@link Contact}
465 *
466 * @return TRUE if the client is ready for another command, FALSE when the
467 * client is done
468 *
469 * @throws IOException
470 * in case of IO error
471 *
472 * @throw InvalidParameterException in case of invalid subcommand
473 */
474 private boolean processDataCmd(SimpleSocket s, Contact contact)
475 throws IOException {
476 Command cmd = s.receiveCommand();
477 Command.Verb verb = cmd.getVerb();
478
479 if (verb == null)
480 return false;
481
482 boolean clientContinue = true;
483
484 System.out.println(s + " -> " + verb);
485
486 switch (verb) {
487 case GET_DATA: {
488 Data data = contact.getById(cmd.getParam());
489 if (data != null)
490 s.sendBlock(Vcard21Parser.toStrings(data));
491 else
492 s.sendBlock();
493 break;
494 }
495 case POST_DATA: {
496 String cstate = cmd.getParam();
497 Data data = null;
498 for (Data d : contact) {
499 if (cstate.equals(d.getContentState()))
500 data = d;
501 }
502
503 if (data != null)
504 data.delete();
505 List<Data> list = Vcard21Parser.parseData(s.receiveBlock());
506 if (list.size() > 0) {
507 contact.add(list.get(0));
508 }
509 break;
510 }
511 case DELETE_DATA: {
512 String cstate = cmd.getParam();
513 Data data = null;
514 for (Data d : contact) {
515 if (cstate.equals(d.getContentState()))
516 data = d;
517 }
518
519 if (data == null) {
520 throw new InvalidParameterException(
521 "Cannot find data to delete for content state: "
522 + cstate);
523 }
524
525 contact.delete();
526 break;
527 }
528 case PUT_CONTACT: {
529 clientContinue = false;
530 break;
531 }
532 default: {
533 throw new InvalidParameterException("command invalid here");
534 }
535 }
536
537 return clientContinue;
538 }
539
540 /**
541 * Return the serialised {@link Card} (with timestamp).
542 *
543 * @param name
544 * the resource name to load
545 *
546 * @return the serialised data
547 *
548 * @throws IOException
549 * in case of error
550 */
551 private List<String> doGetCard(String name) throws IOException {
552 List<String> lines = new LinkedList<String>();
553
554 File vcf = getFile(name);
555
556 if (vcf != null && vcf.exists()) {
557 Card card = new Card(vcf, Format.VCard21);
558
559 // timestamp:
560 lines.add(StringUtils.fromTime(card.getLastModified()));
561 lines.addAll(Vcard21Parser.toStrings(card));
562 }
563
564 return lines;
565 }
566
567 /**
568 * Save the data to the new given resource.
569 *
570 * @param name
571 * the resource name to save
572 * @param data
573 * the data to save
574 *
575 * @return the date of last modification
576 *
577 * @throws IOException
578 * in case of error
579 */
580 private String doPostCard(String name, List<String> data)
581 throws IOException {
582
583 File vcf = getFile(name);
584
585 if (vcf != null) {
586 Card card = new Card(Vcard21Parser.parseContact(data));
587 card.saveAs(vcf, Format.VCard21);
588
589 return StringUtils.fromTime(vcf.lastModified());
590 }
591
592 return "";
593 }
594
595 /**
596 * Return the {@link File} corresponding to the given resource name.
597 *
598 * @param name
599 * the resource name
600 *
601 * @return the corresponding {@link File} or NULL if the name was NULL or
602 * empty
603 */
604 private File getFile(String name) {
605 if (name != null && name.length() > 0) {
606 return new File(dataDir.getAbsolutePath() + File.separator + name);
607 }
608
609 return null;
610 }
611 }