30c404ca223cabcab710b13671ae99fa32cdf0f5
[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
15 import be.nikiroo.jvcard.Card;
16 import be.nikiroo.jvcard.Contact;
17 import be.nikiroo.jvcard.Data;
18 import be.nikiroo.jvcard.parsers.Format;
19 import be.nikiroo.jvcard.parsers.Vcard21Parser;
20 import be.nikiroo.jvcard.remote.SimpleSocket.BlockAppendable;
21 import be.nikiroo.jvcard.resources.RemoteBundle;
22 import be.nikiroo.jvcard.resources.RemotingOption;
23 import be.nikiroo.utils.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 RemoteBundle bundle = new RemoteBundle();
63 try {
64 String dir = bundle.getString(RemotingOption.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(Command.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 CommandInstance cmd = s.receiveCommand();
189 Command command = cmd.getCommand();
190
191 if (command == null)
192 return false;
193
194 boolean clientContinue = true;
195
196 System.out.println(s + " -> " + command
197 + (cmd.getParam() == null ? "" : " " + cmd.getParam()));
198
199 switch (command) {
200 case STOP: {
201 clientContinue = false;
202 break;
203 }
204 case VERSION: {
205 s.sendLine("" + SimpleSocket.CURRENT_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 + command + " (" + 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_CARD: {
262 for (File file : dataDir.listFiles()) {
263 if (cmd.getParam() == null
264 || cmd.getParam().length() == 0
265 || file.getName().toLowerCase()
266 .contains(cmd.getParam().toLowerCase())) {
267 s.send(StringUtils.fromTime(file.lastModified()) + " "
268 + file.getName());
269 }
270 }
271 s.sendBlock();
272 break;
273 }
274 case HELP: {
275 // TODO: i18n
276 s.send("The following commands are available:");
277 s.send("- TIME: get the server time");
278 s.send("- HELP: this help screen");
279 s.send("- LIST_CARD: list the available cards on this server");
280 s.send("- VERSION/GET_*/PUT_*/POST_*/DELETE_*/STOP: TODO");
281 s.sendBlock();
282 break;
283 }
284 default: {
285 System.err
286 .println("Unsupported command received from a client connection, closing it: "
287 + command);
288 clientContinue = false;
289 break;
290 }
291 }
292
293 return clientContinue;
294 }
295
296 /**
297 * Process a subcommand while protected for resource <tt>name</tt>.
298 *
299 * @param s
300 * the {@link SimpleSocket} to process
301 *
302 * @param name
303 * the resource that is protected (and to target)
304 *
305 * @return TRUE if the client is ready for another command, FALSE when the
306 * client is done
307 *
308 * @throws IOException
309 * in case of IO error
310 *
311 * @throw InvalidParameterException in case of invalid subcommand
312 */
313 private boolean processLockedCmd(SimpleSocket s, String name)
314 throws IOException {
315 CommandInstance cmd = s.receiveCommand();
316 Command command = cmd.getCommand();
317
318 if (command == null)
319 return false;
320
321 boolean clientContinue = true;
322
323 System.out.println(s + " -> " + command);
324
325 switch (command) {
326 case GET_CARD: {
327 sendCardBlock(s, name);
328 break;
329 }
330 case POST_CARD: {
331 s.sendLine(doPostCard(name, s.receiveBlock()));
332 break;
333 }
334 case PUT_CARD: {
335 File vcf = getFile(name);
336 if (vcf == null) {
337 System.err
338 .println("Fail to update a card, file not available: "
339 + name);
340 clientContinue = false;
341 } else {
342 Card card = new Card(vcf, Format.VCard21);
343 try {
344 while (processContactCmd(s, card))
345 ;
346 card.save();
347 s.sendLine(StringUtils.fromTime(card.getLastModified()));
348 } catch (InvalidParameterException e) {
349 System.err
350 .println("Unsupported command received from a client connection, closing it: "
351 + command + " (" + e.getMessage() + ")");
352 clientContinue = false;
353 }
354 }
355 break;
356 }
357 case DELETE_CARD: {
358 // TODO
359 System.err
360 .println("Unsupported command received from a client connection, closing it: "
361 + command);
362 clientContinue = false;
363 break;
364 }
365 case SELECT: {
366 clientContinue = false;
367 break;
368 }
369 default: {
370 throw new InvalidParameterException("command invalid here: "
371 + command);
372 }
373 }
374
375 return clientContinue;
376 }
377
378 /**
379 * Process a *_CONTACT subcommand.
380 *
381 * @param s
382 * the {@link SimpleSocket} to process
383 * @param card
384 * the target {@link Card}
385 *
386 * @return TRUE if the client is ready for another command, FALSE when the
387 * client is done
388 *
389 * @throws IOException
390 * in case of IO error
391 *
392 * @throw InvalidParameterException in case of invalid subcommand
393 */
394 private boolean processContactCmd(SimpleSocket s, Card card)
395 throws IOException {
396 CommandInstance cmd = s.receiveCommand();
397 Command command = cmd.getCommand();
398
399 if (command == null)
400 return false;
401
402 boolean clientContinue = true;
403
404 System.out.println(s + " -> " + command);
405
406 switch (command) {
407 case GET_CONTACT: {
408 Contact contact = card.getById(cmd.getParam());
409 if (contact != null) {
410 BlockAppendable app = s.createBlockAppendable();
411 Vcard21Parser.write(app, contact, -1);
412 app.close();
413 } else {
414 s.sendBlock();
415 }
416 break;
417 }
418 case POST_CONTACT: {
419 List<Contact> list = Vcard21Parser.parseContact(s.receiveBlock());
420 if (list.size() > 0) {
421 Contact newContact = list.get(0);
422 String uid = newContact.getPreferredDataValue("UID");
423 Contact oldContact = card.getById(uid);
424 if (oldContact != null)
425 oldContact.delete();
426 card.add(newContact);
427 }
428
429 break;
430 }
431 case PUT_CONTACT: {
432 String uid = cmd.getParam();
433 Contact contact = card.getById(uid);
434 if (contact == null) {
435 throw new InvalidParameterException(
436 "Cannot find contact to modify for UID: " + uid);
437 }
438 while (processDataCmd(s, contact))
439 ;
440 break;
441 }
442 case DELETE_CONTACT: {
443 String uid = cmd.getParam();
444 Contact contact = card.getById(uid);
445 if (contact == null) {
446 throw new InvalidParameterException(
447 "Cannot find contact to delete for UID: " + uid);
448 }
449
450 contact.delete();
451 break;
452 }
453 case HASH_CONTACT: {
454 String uid = cmd.getParam();
455 Contact contact = card.getById(uid);
456
457 if (contact == null) {
458 s.sendBlock();
459 } else {
460 s.sendLine(contact.getContentState(true));
461 }
462 break;
463 }
464 case LIST_CONTACT: {
465 for (Contact contact : card) {
466 if (cmd.getParam() == null
467 || cmd.getParam().length() == 0
468 || (contact.getPreferredDataValue("FN") + contact
469 .getPreferredDataValue("N")).toLowerCase()
470 .contains(cmd.getParam().toLowerCase())) {
471 s.send(contact.getContentState(true) + " "
472 + contact.getId());
473 }
474 }
475 s.sendBlock();
476 break;
477 }
478 case PUT_CARD: {
479 clientContinue = false;
480 break;
481 }
482 default: {
483 throw new InvalidParameterException("command invalid here: "
484 + command);
485 }
486 }
487
488 return clientContinue;
489 }
490
491 /**
492 * Process a *_DATA subcommand.
493 *
494 * @param s
495 * the {@link SimpleSocket} to process
496 * @param card
497 * the target {@link Contact}
498 *
499 * @return TRUE if the client is ready for another command, FALSE when the
500 * client is done
501 *
502 * @throws IOException
503 * in case of IO error
504 *
505 * @throw InvalidParameterException in case of invalid subcommand
506 */
507 private boolean processDataCmd(SimpleSocket s, Contact contact)
508 throws IOException {
509 CommandInstance cmd = s.receiveCommand();
510 Command command = cmd.getCommand();
511
512 if (command == null)
513 return false;
514
515 boolean clientContinue = true;
516
517 System.out.println(s + " -> " + command);
518
519 switch (command) {
520 case GET_DATA: {
521 for (Data data : contact) {
522 if (data.getName().equals(cmd.getParam())) {
523 BlockAppendable app = s.createBlockAppendable();
524 Vcard21Parser.write(app, data);
525 // note: we do NOT close 'app', since it would send an EOB
526 }
527 }
528 s.sendBlock();
529 break;
530 }
531 case POST_DATA: {
532 String cstate = cmd.getParam();
533 Data data = null;
534 for (Data d : contact) {
535 if (cstate.equals(d.getContentState(true)))
536 data = d;
537 }
538
539 if (data != null)
540 data.delete();
541 List<Data> list = Vcard21Parser.parseData(s.receiveBlock());
542 if (list.size() > 0) {
543 contact.add(list.get(0));
544 }
545 break;
546 }
547 case DELETE_DATA: {
548 String cstate = cmd.getParam();
549 Data data = null;
550 for (Data d : contact) {
551 if (cstate.equals(d.getContentState(true)))
552 data = d;
553 }
554
555 if (data == null) {
556 throw new InvalidParameterException(
557 "Cannot find data to delete for content state: "
558 + cstate);
559 }
560
561 contact.delete();
562 break;
563 }
564 case HASH_DATA: {
565 for (Data data : contact) {
566 if (data.getId().equals(cmd.getParam())) {
567 s.send(data.getContentState(true));
568 }
569 }
570 s.sendBlock();
571 break;
572 }
573 case LIST_DATA: {
574 for (Data data : contact) {
575 if (cmd.getParam() == null
576 || cmd.getParam().length() == 0
577 || data.getName().toLowerCase()
578 .contains(cmd.getParam().toLowerCase())) {
579 s.send(data.getContentState(true) + " " + data.getName());
580 }
581 }
582 s.sendBlock();
583 break;
584 }
585 case PUT_CONTACT: {
586 clientContinue = false;
587 break;
588 }
589 default: {
590 throw new InvalidParameterException("command invalid here: "
591 + command);
592 }
593 }
594
595 return clientContinue;
596 }
597
598 /**
599 * Return the serialised {@link Card} (with timestamp).
600 *
601 * @param name
602 * the resource name to load
603 *
604 * @return the serialised data
605 *
606 * @throws IOException
607 * in case of error
608 */
609 private void sendCardBlock(SimpleSocket s, String name) throws IOException {
610 File vcf = getFile(name);
611 BlockAppendable app = s.createBlockAppendable();
612
613 if (vcf != null && vcf.exists()) {
614 Card card = new Card(vcf, Format.VCard21);
615
616 // timestamp + data
617 app.append(StringUtils.fromTime(card.getLastModified()) + "\r\n");
618 Vcard21Parser.write(app, card);
619 }
620
621 app.close();
622 }
623
624 /**
625 * Save the data to the new given resource.
626 *
627 * @param name
628 * the resource name to save
629 * @param data
630 * the data to save
631 *
632 * @return the date of last modification
633 *
634 * @throws IOException
635 * in case of error
636 */
637 private String doPostCard(String name, List<String> data)
638 throws IOException {
639
640 File vcf = getFile(name);
641
642 if (vcf != null) {
643 Card card = new Card(Vcard21Parser.parseContact(data));
644 card.saveAs(vcf, Format.VCard21);
645
646 return StringUtils.fromTime(vcf.lastModified());
647 }
648
649 return "";
650 }
651
652 /**
653 * Return the {@link File} corresponding to the given resource name.
654 *
655 * @param name
656 * the resource name
657 *
658 * @return the corresponding {@link File} or NULL if the name was NULL or
659 * empty
660 */
661 private File getFile(String name) {
662 if (name != null && name.length() > 0) {
663 return new File(dataDir.getAbsolutePath() + File.separator + name);
664 }
665
666 return null;
667 }
668 }