Warn/error on making KDMs using recipient certs whose validity periods
[dcpomatic.git] / src / lib / kdm_cli.cc
1 /*
2     Copyright (C) 2013-2022 Carl Hetherington <cth@carlh.net>
3
4     This file is part of DCP-o-matic.
5
6     DCP-o-matic is free software; you can redistribute it and/or modify
7     it under the terms of the GNU General Public License as published by
8     the Free Software Foundation; either version 2 of the License, or
9     (at your option) any later version.
10
11     DCP-o-matic is distributed in the hope that it will be useful,
12     but WITHOUT ANY WARRANTY; without even the implied warranty of
13     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14     GNU General Public License for more details.
15
16     You should have received a copy of the GNU General Public License
17     along with DCP-o-matic.  If not, see <http://www.gnu.org/licenses/>.
18
19 */
20
21
22 /** @file  src/tools/dcpomatic_kdm_cli.cc
23  *  @brief Command-line program to generate KDMs.
24  */
25
26
27 #include "cinema.h"
28 #include "config.h"
29 #include "dkdm_wrapper.h"
30 #include "emailer.h"
31 #include "exceptions.h"
32 #include "film.h"
33 #include "kdm_with_metadata.h"
34 #include "screen.h"
35 #include <dcp/certificate.h>
36 #include <dcp/decrypted_kdm.h>
37 #include <dcp/encrypted_kdm.h>
38 #include <getopt.h>
39
40
41 using std::dynamic_pointer_cast;
42 using std::list;
43 using std::make_shared;
44 using std::runtime_error;
45 using std::shared_ptr;
46 using std::string;
47 using std::vector;
48 using boost::optional;
49 using boost::bind;
50 #if BOOST_VERSION >= 106100
51 using namespace boost::placeholders;
52 #endif
53 using namespace dcpomatic;
54
55
56 static void
57 help (std::function<void (string)> out)
58 {
59         out (String::compose("Syntax: %1 [OPTION] <FILM|CPL-ID|DKDM>", program_name));
60         out ("  -h, --help                               show this help");
61         out ("  -o, --output                             output file or directory");
62         out ("  -K, --filename-format                    filename format for KDMs");
63         out ("  -Z, --container-name-format              filename format for ZIP containers");
64         out ("  -f, --valid-from                         valid from time (in local time zone of the cinema) (e.g. \"2013-09-28 01:41:51\") or \"now\"");
65         out ("  -t, --valid-to                           valid to time (in local time zone of the cinema) (e.g. \"2014-09-28 01:41:51\")");
66         out ("  -d, --valid-duration                     valid duration (e.g. \"1 day\", \"4 hours\", \"2 weeks\")");
67         out ("  -F, --formulation                        modified-transitional-1, multiple-modified-transitional-1, dci-any or dci-specific [default modified-transitional-1]");
68         out ("  -p, --disable-forensic-marking-picture   disable forensic marking of pictures essences");
69         out ("  -a, --disable-forensic-marking-audio     disable forensic marking of audio essences (optionally above a given channel, e.g 12)");
70         out ("  -e, --email                              email KDMs to cinemas");
71         out ("  -z, --zip                                ZIP each cinema's KDMs into its own file");
72         out ("  -v, --verbose                            be verbose");
73         out ("  -c, --cinema                             cinema name (when using -C) or name/email (to filter cinemas)");
74         out ("  -S, --screen                             screen name (when using -C) or screen name (to filter screens when using -c)");
75         out ("  -C, --certificate                        file containing projector certificate");
76         out ("  -T, --trusted-device                     file containing a trusted device's certificate");
77         out ("      --list-cinemas                       list known cinemas from the DCP-o-matic settings");
78         out ("      --list-dkdm-cpls                     list CPLs for which DCP-o-matic has DKDMs");
79         out ("");
80         out ("CPL-ID must be the ID of a CPL that is mentioned in DCP-o-matic's DKDM list.");
81         out ("");
82         out ("For example:");
83         out ("");
84         out ("Create KDMs for my_great_movie to play in all of Fred's Cinema's screens for the next two weeks and zip them up.");
85         out ("(Fred's Cinema must have been set up in DCP-o-matic's KDM window)");
86         out ("");
87         out (String::compose("\t%1 -c \"Fred's Cinema\" -f now -d \"2 weeks\" -z my_great_movie", program_name));
88 }
89
90
91 class KDMCLIError : public std::runtime_error
92 {
93 public:
94         KDMCLIError (std::string message)
95                 : std::runtime_error (String::compose("%1: %2", program_name, message).c_str())
96         {}
97 };
98
99
100 static boost::posix_time::ptime
101 time_from_string (string t)
102 {
103         if (t == "now") {
104                 return boost::posix_time::second_clock::local_time ();
105         }
106
107         return boost::posix_time::time_from_string (t);
108 }
109
110
111 static boost::posix_time::time_duration
112 duration_from_string (string d)
113 {
114         int N;
115         char unit_buf[64] = "\0";
116         sscanf (d.c_str(), "%d %63s", &N, unit_buf);
117         string const unit (unit_buf);
118
119         if (N == 0) {
120                 throw KDMCLIError (String::compose("could not understand duration \"%1\"", d));
121         }
122
123         if (unit == "year" || unit == "years") {
124                 return boost::posix_time::time_duration (N * 24 * 365, 0, 0, 0);
125         } else if (unit == "week" || unit == "weeks") {
126                 return boost::posix_time::time_duration (N * 24 * 7, 0, 0, 0);
127         } else if (unit == "day" || unit == "days") {
128                 return boost::posix_time::time_duration (N * 24, 0, 0, 0);
129         } else if (unit == "hour" || unit == "hours") {
130                 return boost::posix_time::time_duration (N, 0, 0, 0);
131         }
132
133         throw KDMCLIError (String::compose("could not understand duration \"%1\"", d));
134 }
135
136
137 static bool
138 always_overwrite ()
139 {
140         return true;
141 }
142
143
144 static
145 void
146 write_files (
147         list<KDMWithMetadataPtr> kdms,
148         bool zip,
149         boost::filesystem::path output,
150         dcp::NameFormat container_name_format,
151         dcp::NameFormat filename_format,
152         bool verbose,
153         std::function<void (string)> out
154         )
155 {
156         if (zip) {
157                 int const N = write_zip_files (
158                         collect (kdms),
159                         output,
160                         container_name_format,
161                         filename_format,
162                         bind (&always_overwrite)
163                         );
164
165                 if (verbose) {
166                         out (String::compose("Wrote %1 ZIP files to %2", N, output));
167                 }
168         } else {
169                 int const N = write_files (
170                         kdms, output, filename_format,
171                         bind (&always_overwrite)
172                         );
173
174                 if (verbose) {
175                         out (String::compose("Wrote %1 KDM files to %2", N, output));
176                 }
177         }
178 }
179
180
181 static
182 shared_ptr<Cinema>
183 find_cinema (string cinema_name)
184 {
185         auto cinemas = Config::instance()->cinemas ();
186         auto i = cinemas.begin();
187         while (
188                 i != cinemas.end() &&
189                 (*i)->name != cinema_name &&
190                 find ((*i)->emails.begin(), (*i)->emails.end(), cinema_name) == (*i)->emails.end()) {
191
192                 ++i;
193         }
194
195         if (i == cinemas.end ()) {
196                 throw KDMCLIError (String::compose("could not find cinema \"%1\"", cinema_name));
197         }
198
199         return *i;
200 }
201
202
203 static
204 void
205 from_film (
206         list<shared_ptr<Screen>> screens,
207         boost::filesystem::path film_dir,
208         bool verbose,
209         boost::filesystem::path output,
210         dcp::NameFormat container_name_format,
211         dcp::NameFormat filename_format,
212         boost::posix_time::ptime valid_from,
213         boost::posix_time::ptime valid_to,
214         dcp::Formulation formulation,
215         bool disable_forensic_marking_picture,
216         optional<int> disable_forensic_marking_audio,
217         bool email,
218         bool zip,
219         std::function<void (string)> out
220         )
221 {
222         shared_ptr<Film> film;
223         try {
224                 film = make_shared<Film>(film_dir);
225                 film->read_metadata ();
226                 if (verbose) {
227                         out (String::compose("Read film %1", film->name()));
228                 }
229         } catch (std::exception& e) {
230                 throw KDMCLIError (String::compose("error reading film \"%1\" (%2)", film_dir.string(), e.what()));
231         }
232
233         /* XXX: allow specification of this */
234         vector<CPLSummary> cpls = film->cpls ();
235         if (cpls.empty ()) {
236                 throw KDMCLIError ("no CPLs found in film");
237         } else if (cpls.size() > 1) {
238                 throw KDMCLIError ("more than one CPL found in film");
239         }
240
241         auto cpl = cpls.front().cpl_file;
242
243         std::vector<KDMCertificatePeriod> period_checks;
244
245         try {
246                 list<KDMWithMetadataPtr> kdms;
247                 for (auto i: screens) {
248                         auto p = kdm_for_screen(film, cpl, i, valid_from, valid_to, formulation, disable_forensic_marking_picture, disable_forensic_marking_audio, period_checks);
249                         if (p) {
250                                 kdms.push_back (p);
251                         }
252                 }
253
254
255                 if (find(period_checks.begin(), period_checks.end(), KDMCertificatePeriod::KDM_OUTSIDE_CERTIFICATE) != period_checks.end()) {
256                         throw KDMCLIError(
257                                 "Some KDMs would have validity periods which are completely outside the recipient certificate periods.  Such KDMs are very unlikely to work, so will not be created."
258                                 );
259                 }
260
261                 if (find(period_checks.begin(), period_checks.end(), KDMCertificatePeriod::KDM_OVERLAPS_CERTIFICATE) != period_checks.end()) {
262                         out("For some of these KDMs the recipient certificate's validity period will not cover the whole of the KDM validity period.  This might cause problems with the KDMs.");
263                 }
264
265                 write_files (kdms, zip, output, container_name_format, filename_format, verbose, out);
266                 if (email) {
267                         send_emails ({kdms}, container_name_format, filename_format, film->dcp_name(), {});
268                 }
269         } catch (FileError& e) {
270                 throw KDMCLIError (String::compose("%1 (%2)", e.what(), e.file().string()));
271         }
272 }
273
274
275 static
276 optional<dcp::EncryptedKDM>
277 sub_find_dkdm (shared_ptr<DKDMGroup> group, string cpl_id)
278 {
279         for (auto i: group->children()) {
280                 auto g = dynamic_pointer_cast<DKDMGroup>(i);
281                 if (g) {
282                         auto dkdm = sub_find_dkdm (g, cpl_id);
283                         if (dkdm) {
284                                 return dkdm;
285                         }
286                 } else {
287                         auto d = dynamic_pointer_cast<DKDM>(i);
288                         assert (d);
289                         if (d->dkdm().cpl_id() == cpl_id) {
290                                 return d->dkdm();
291                         }
292                 }
293         }
294
295         return {};
296 }
297
298
299 static
300 optional<dcp::EncryptedKDM>
301 find_dkdm (string cpl_id)
302 {
303         return sub_find_dkdm (Config::instance()->dkdms(), cpl_id);
304 }
305
306
307 static
308 dcp::EncryptedKDM
309 kdm_from_dkdm (
310         dcp::DecryptedKDM dkdm,
311         dcp::Certificate target,
312         vector<string> trusted_devices,
313         dcp::LocalTime valid_from,
314         dcp::LocalTime valid_to,
315         dcp::Formulation formulation,
316         bool disable_forensic_marking_picture,
317         optional<int> disable_forensic_marking_audio
318         )
319 {
320         /* Signer for new KDM */
321         auto signer = Config::instance()->signer_chain ();
322         if (!signer->valid ()) {
323                 throw KDMCLIError ("signing certificate chain is invalid.");
324         }
325
326         /* Make a new empty KDM and add the keys from the DKDM to it */
327         dcp::DecryptedKDM kdm (
328                 valid_from,
329                 valid_to,
330                 dkdm.annotation_text().get_value_or(""),
331                 dkdm.content_title_text(),
332                 dcp::LocalTime().as_string()
333                 );
334
335         for (auto const& j: dkdm.keys()) {
336                 kdm.add_key(j);
337         }
338
339         return kdm.encrypt (signer, target, trusted_devices, formulation, disable_forensic_marking_picture, disable_forensic_marking_audio);
340 }
341
342
343 static
344 void
345 from_dkdm (
346         list<shared_ptr<Screen>> screens,
347         dcp::DecryptedKDM dkdm,
348         bool verbose,
349         boost::filesystem::path output,
350         dcp::NameFormat container_name_format,
351         dcp::NameFormat filename_format,
352         boost::posix_time::ptime valid_from,
353         boost::posix_time::ptime valid_to,
354         dcp::Formulation formulation,
355         bool disable_forensic_marking_picture,
356         optional<int> disable_forensic_marking_audio,
357         bool email,
358         bool zip,
359         std::function<void (string)> out
360         )
361 {
362         dcp::NameFormat::Map values;
363
364         try {
365                 list<KDMWithMetadataPtr> kdms;
366                 for (auto i: screens) {
367                         if (!i->recipient) {
368                                 continue;
369                         }
370
371                         int const offset_hour = i->cinema ? i->cinema->utc_offset_hour() : 0;
372                         int const offset_minute = i->cinema ? i->cinema->utc_offset_minute() : 0;
373
374                         dcp::LocalTime begin(valid_from, dcp::UTCOffset(offset_hour, offset_minute));
375                         dcp::LocalTime end(valid_to, dcp::UTCOffset(offset_hour, offset_minute));
376
377                         auto const kdm = kdm_from_dkdm(
378                                                         dkdm,
379                                                         i->recipient.get(),
380                                                         i->trusted_device_thumbprints(),
381                                                         begin,
382                                                         end,
383                                                         formulation,
384                                                         disable_forensic_marking_picture,
385                                                         disable_forensic_marking_audio
386                                                         );
387
388                         dcp::NameFormat::Map name_values;
389                         name_values['c'] = i->cinema ? i->cinema->name : "";
390                         name_values['s'] = i->name;
391                         name_values['f'] = kdm.content_title_text();
392                         name_values['b'] = begin.date() + " " + begin.time_of_day(true, false);
393                         name_values['e'] = end.date() + " " + end.time_of_day(true, false);
394                         name_values['i'] = kdm.cpl_id();
395
396                         kdms.push_back (make_shared<KDMWithMetadata>(name_values, i->cinema.get(), i->cinema ? i->cinema->emails : list<string>(), kdm));
397                 }
398                 write_files (kdms, zip, output, container_name_format, filename_format, verbose, out);
399                 if (email) {
400                         send_emails ({kdms}, container_name_format, filename_format, dkdm.annotation_text().get_value_or(""), {});
401                 }
402         } catch (FileError& e) {
403                 throw KDMCLIError (String::compose("%1 (%2)", e.what(), e.file().string()));
404         }
405 }
406
407
408 static
409 void
410 dump_dkdm_group (shared_ptr<DKDMGroup> group, int indent, std::function<void (string)> out)
411 {
412         auto const indent_string = string(indent, ' ');
413
414         if (indent > 0) {
415                 out (indent_string + group->name());
416         }
417         for (auto i: group->children()) {
418                 auto g = dynamic_pointer_cast<DKDMGroup>(i);
419                 if (g) {
420                         dump_dkdm_group (g, indent + 2, out);
421                 } else {
422                         auto d = dynamic_pointer_cast<DKDM>(i);
423                         assert(d);
424                         out (indent_string + d->dkdm().cpl_id());
425                 }
426         }
427 }
428
429
430 optional<string>
431 kdm_cli (int argc, char* argv[], std::function<void (string)> out)
432 try
433 {
434         boost::filesystem::path output = boost::filesystem::current_path();
435         auto container_name_format = Config::instance()->kdm_container_name_format();
436         auto filename_format = Config::instance()->kdm_filename_format();
437         optional<string> cinema_name;
438         shared_ptr<Cinema> cinema;
439         optional<boost::filesystem::path> certificate;
440         optional<string> screen;
441         list<shared_ptr<Screen>> screens;
442         optional<dcp::EncryptedKDM> dkdm;
443         optional<boost::posix_time::ptime> valid_from;
444         optional<boost::posix_time::ptime> valid_to;
445         bool zip = false;
446         bool list_cinemas = false;
447         bool list_dkdm_cpls = false;
448         optional<string> duration_string;
449         bool verbose = false;
450         dcp::Formulation formulation = dcp::Formulation::MODIFIED_TRANSITIONAL_1;
451         bool disable_forensic_marking_picture = false;
452         optional<int> disable_forensic_marking_audio;
453         bool email = false;
454
455         program_name = argv[0];
456
457         /* Reset getopt() so we can call this method several times in one test process */
458         optind = 1;
459
460         int option_index = 0;
461         while (true) {
462                 static struct option long_options[] = {
463                         { "help", no_argument, 0, 'h'},
464                         { "output", required_argument, 0, 'o'},
465                         { "filename-format", required_argument, 0, 'K'},
466                         { "container-name-format", required_argument, 0, 'Z'},
467                         { "valid-from", required_argument, 0, 'f'},
468                         { "valid-to", required_argument, 0, 't'},
469                         { "valid-duration", required_argument, 0, 'd'},
470                         { "formulation", required_argument, 0, 'F' },
471                         { "disable-forensic-marking-picture", no_argument, 0, 'p' },
472                         { "disable-forensic-marking-audio", optional_argument, 0, 'a' },
473                         { "email", no_argument, 0, 'e' },
474                         { "zip", no_argument, 0, 'z' },
475                         { "verbose", no_argument, 0, 'v' },
476                         { "cinema", required_argument, 0, 'c' },
477                         { "screen", required_argument, 0, 'S' },
478                         { "certificate", required_argument, 0, 'C' },
479                         { "trusted-device", required_argument, 0, 'T' },
480                         { "list-cinemas", no_argument, 0, 'B' },
481                         { "list-dkdm-cpls", no_argument, 0, 'D' },
482                         { 0, 0, 0, 0 }
483                 };
484
485                 int c = getopt_long (argc, argv, "ho:K:Z:f:t:d:F:pae::zvc:S:C:T:BD", long_options, &option_index);
486
487                 if (c == -1) {
488                         break;
489                 }
490
491                 switch (c) {
492                 case 'h':
493                         help (out);
494                         exit (EXIT_SUCCESS);
495                 case 'o':
496                         output = optarg;
497                         break;
498                 case 'K':
499                         filename_format = dcp::NameFormat (optarg);
500                         break;
501                 case 'Z':
502                         container_name_format = dcp::NameFormat (optarg);
503                         break;
504                 case 'f':
505                         valid_from = time_from_string (optarg);
506                         break;
507                 case 't':
508                         valid_to = time_from_string (optarg);
509                         break;
510                 case 'd':
511                         duration_string = optarg;
512                         break;
513                 case 'F':
514                         if (string(optarg) == "modified-transitional-1") {
515                                 formulation = dcp::Formulation::MODIFIED_TRANSITIONAL_1;
516                         } else if (string(optarg) == "multiple-modified-transitional-1") {
517                                 formulation = dcp::Formulation::MULTIPLE_MODIFIED_TRANSITIONAL_1;
518                         } else if (string(optarg) == "dci-any") {
519                                 formulation = dcp::Formulation::DCI_ANY;
520                         } else if (string(optarg) == "dci-specific") {
521                                 formulation = dcp::Formulation::DCI_SPECIFIC;
522                         } else {
523                                 throw KDMCLIError ("unrecognised KDM formulation " + string (optarg));
524                         }
525                         break;
526                 case 'p':
527                         disable_forensic_marking_picture = true;
528                         break;
529                 case 'a':
530                         disable_forensic_marking_audio = 0;
531                         if (optarg == 0 && argv[optind] != 0 && argv[optind][0] != '-') {
532                                 disable_forensic_marking_audio = atoi (argv[optind++]);
533                         } else if (optarg) {
534                                 disable_forensic_marking_audio = atoi (optarg);
535                         }
536                         break;
537                 case 'e':
538                         email = true;
539                         break;
540                 case 'z':
541                         zip = true;
542                         break;
543                 case 'v':
544                         verbose = true;
545                         break;
546                 case 'c':
547                         /* This could be a cinema to search for in the configured list or the name of a cinema being
548                            built up on-the-fly in the option.  Cater for both possilibities here by storing the name
549                            (for lookup) and by creating a Cinema which the next Screen will be added to.
550                         */
551                         cinema_name = optarg;
552                         cinema = make_shared<Cinema>(optarg, list<string>(), "", 0, 0);
553                         break;
554                 case 'S':
555                         /* Similarly, this could be the name of a new (temporary) screen or the name of a screen
556                          * to search for.
557                          */
558                         screen = optarg;
559                         break;
560                 case 'C':
561                         certificate = optarg;
562                         break;
563                 case 'T':
564                         /* A trusted device ends up in the last screen we made */
565                         if (!screens.empty ()) {
566                                 screens.back()->trusted_devices.push_back(TrustedDevice(dcp::Certificate(dcp::file_to_string(optarg))));
567                         }
568                         break;
569                 case 'B':
570                         list_cinemas = true;
571                         break;
572                 case 'D':
573                         list_dkdm_cpls = true;
574                         break;
575                 }
576         }
577
578         if (certificate) {
579                 /* Make a new screen and add it to the current cinema */
580                 dcp::CertificateChain chain(dcp::file_to_string(*certificate));
581                 auto screen_to_add = std::make_shared<Screen>(screen.get_value_or(""), "", chain.leaf(), boost::none, vector<TrustedDevice>());
582                 if (cinema) {
583                         cinema->add_screen(screen_to_add);
584                 }
585                 screens.push_back(screen_to_add);
586         }
587
588         if (list_cinemas) {
589                 auto cinemas = Config::instance()->cinemas ();
590                 for (auto i: cinemas) {
591                         out (String::compose("%1 (%2)", i->name, Emailer::address_list (i->emails)));
592                 }
593                 exit (EXIT_SUCCESS);
594         }
595
596         if (list_dkdm_cpls) {
597                 dump_dkdm_group (Config::instance()->dkdms(), 0, out);
598                 exit (EXIT_SUCCESS);
599         }
600
601         if (!duration_string && !valid_to) {
602                 throw KDMCLIError ("you must specify a --valid-duration or --valid-to");
603         }
604
605         if (!valid_from) {
606                 throw KDMCLIError ("you must specify --valid-from");
607         }
608
609         if (optind >= argc) {
610                 throw KDMCLIError ("no film, CPL ID or DKDM specified");
611         }
612
613         if (screens.empty()) {
614                 if (!cinema_name) {
615                         throw KDMCLIError ("you must specify either a cinema or one or more screens using certificate files");
616                 }
617
618                 screens = find_cinema (*cinema_name)->screens ();
619                 if (screen) {
620                         screens.erase(std::remove_if(screens.begin(), screens.end(), [&screen](shared_ptr<Screen> s) { return s->name != *screen; }), screens.end());
621                 }
622         }
623
624         if (duration_string) {
625                 valid_to = valid_from.get() + duration_from_string (*duration_string);
626         }
627
628         if (verbose) {
629                 out (String::compose("Making KDMs valid from %1 to %2", boost::posix_time::to_simple_string(valid_from.get()), boost::posix_time::to_simple_string(valid_to.get())));
630         }
631
632         string const thing = argv[optind];
633         if (boost::filesystem::is_directory(thing) && boost::filesystem::is_regular_file(boost::filesystem::path(thing) / "metadata.xml")) {
634                 from_film (
635                         screens,
636                         thing,
637                         verbose,
638                         output,
639                         container_name_format,
640                         filename_format,
641                         *valid_from,
642                         *valid_to,
643                         formulation,
644                         disable_forensic_marking_picture,
645                         disable_forensic_marking_audio,
646                         email,
647                         zip,
648                         out
649                         );
650         } else {
651                 if (boost::filesystem::is_regular_file(thing)) {
652                         dkdm = dcp::EncryptedKDM (dcp::file_to_string (thing));
653                 } else {
654                         dkdm = find_dkdm (thing);
655                 }
656
657                 if (!dkdm) {
658                         throw KDMCLIError ("could not find film or CPL ID corresponding to " + thing);
659                 }
660
661                 from_dkdm (
662                         screens,
663                         dcp::DecryptedKDM (*dkdm, Config::instance()->decryption_chain()->key().get()),
664                         verbose,
665                         output,
666                         container_name_format,
667                         filename_format,
668                         *valid_from,
669                         *valid_to,
670                         formulation,
671                         disable_forensic_marking_picture,
672                         disable_forensic_marking_audio,
673                         email,
674                         zip,
675                         out
676                         );
677         }
678
679         return {};
680 } catch (std::exception& e) {
681         return string(e.what());
682 }
683