├── .gitignore ├── LICENSE ├── README.md ├── build.sla ├── contrib ├── debian │ └── DEBIAN │ │ ├── conffiles │ │ └── control ├── release_Makefile ├── scriptform.init.d_debian ├── scriptform.init.d_redhat └── scriptform.service ├── doc ├── DEV.md ├── MANUAL.md ├── footer.html ├── header.html └── screenshots │ ├── form.png │ ├── list.png │ └── result.png ├── examples ├── auth │ ├── README.md │ ├── auth.json │ └── job_do_nothing.sh ├── customize │ ├── custom.css │ ├── customize.json │ └── job_customize.sh ├── dynamic_forms │ ├── README.md │ ├── dynamic_forms.json │ ├── form_dyn_fields.sh │ ├── form_dyn_options_target_db.sh │ └── job_import.sh ├── megacorp_acc │ ├── README.md │ ├── job_clean_database.sh │ ├── job_download_db.sh │ ├── job_enable_firewall.sh │ ├── job_import_employees.sh │ ├── job_list_employees.sh │ ├── job_restart_acc.sh │ ├── job_signup_step1.sh │ ├── job_signup_step2.sh │ ├── megacorp_acc.json │ ├── megacorp_employees.csv │ └── megacorp_empty.sql ├── output_types │ ├── README.md │ ├── job_large_bin.sh │ ├── job_show_html.sh │ ├── job_show_image.sh │ ├── output.json │ └── test.jpg ├── run_as │ ├── README.md │ ├── job_run_as.py │ └── run_as.json ├── simple │ ├── README.md │ ├── htpasswd │ ├── job_add_user.sh │ ├── job_import.sh │ └── simple.json ├── static_serve │ ├── job_serve.sh │ ├── static │ │ └── ssh_server.png │ └── static_serve.json ├── tutorial │ ├── job_helloworld.py │ ├── job_helloworld.sh │ ├── job_sysinfo.sh │ ├── job_sysinfo_output.sh │ ├── job_upload.sh │ ├── tutorial.json │ ├── tutorial_fields.json │ ├── tutorial_output.json │ ├── tutorial_upload.json │ └── tutorial_validate.json └── validate │ ├── README.md │ ├── job_validate.sh │ └── validate.json ├── requirements-dev.txt ├── src ├── daemon.py ├── formconfig.py ├── formdefinition.py ├── formrender.py ├── runscript.py ├── scriptform.py ├── webapp.py └── webserver.py └── test ├── static └── ssh_server.png ├── test.py ├── test.sh ├── test_formconfig_basic.json ├── test_formconfig_callback.json ├── test_formconfig_callback.sh ├── test_formconfig_hidden.json ├── test_formconfig_missingscript.json ├── test_formconfig_noexec.json ├── test_formdefinition_missing_title.json ├── test_formdefinition_validate.json ├── test_noexec.sh ├── test_scriptform_list.json ├── test_upload.sh ├── test_webapp.json ├── test_webapp_cb_fail.sh └── test_webapp_singleform.json /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | *.pyc 3 | *.log 4 | *.rpm 5 | *.deb 6 | *.tar.gz 7 | *.zip 8 | *.pid 9 | doc/MANUAL.html 10 | README.html 11 | examples/native/htaccess 12 | examples/simple/htaccess 13 | examples/megacorp_acc/megacorp.db 14 | test/.coverage 15 | test/htmlcov 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2015 Ferry Boender. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScriptForm 2 | 3 | ![Status: Stable](https://img.shields.io/badge/status-stable-green.svg) 4 | ![Build Status](http://build.electricmonk.nl/job/scriptform/shield) 5 | ![Activity: Active development](https://img.shields.io/badge/activity-active%20development-green.svg) 6 | ![License: GPLv3](https://img.shields.io/badge/license-GPLv3-blue.svg) 7 | 8 | A stand-alone webserver that automatically generates forms from JSON to serve 9 | as frontends to scripts. 10 | 11 | ScriptForm takes a JSON file which contains form definitions. It then 12 | constructs web forms from this JSON and serves these to users over HTTP. The 13 | user can select a form and fill it out. When the user submits the form, it is 14 | validated and the associated script is called. Data entered in the form is 15 | passed to the script through the environment. 16 | 17 | Packages are available for: 18 | 19 | * [Debian / Ubuntu](https://github.com/fboender/scriptform/releases) 20 | * [RedHat / Centos](https://github.com/fboender/scriptform/releases) 21 | * [Other operating systems](https://github.com/fboender/scriptform/releases) 22 | 23 | ## Features 24 | 25 | - Very rapidly construct forms with backends. 26 | - Completely standalone HTTP server; only requires Python. 27 | - Callbacks to any kind of script / program that supports environment 28 | variables. 29 | - User authentication support through Basic HTAuth. 30 | - Validates form values before calling scripts. 31 | - Uploaded files are automatically saved to temporary files, which are passed 32 | on to the callback. 33 | - Multiple forms in a single JSON definition file. 34 | - Scripts can produce normal output, HTML output or stream their own HTTP 35 | response to the client. The last one lets you stream images or binaries to 36 | the browser. 37 | - Run scripts as different users without requiring sudo. 38 | - Audit log: All form submissions including entered values can be logged to a 39 | logfile for auditing. 40 | 41 | 42 | ## Use-cases 43 | 44 | Scriptform is very flexible and as such serves many use-cases. Most of these 45 | revolve around giving non-technical users a user friendly way to safely run 46 | scripts on a server. 47 | 48 | Here are some of the potential uses of Scriptform: 49 | 50 | - Add / remove users from htpasswd files. 51 | - Execute SQL snippets. 52 | - View service status 53 | - Upload data to be processed. 54 | - Restart, enable and disable services. 55 | - Trigger for batch processing. 56 | 57 | ## Example 58 | 59 | The following example lets you add new users to a htpasswd file via ScriptForm. 60 | It presents the user with a form to enter the user's details. When the form is 61 | submitted, the `job_add_user.sh` script is called which adds the user to the 62 | htpasswd file. 63 | 64 | 65 | Form configuration file: `test_server.json` 66 | 67 | { 68 | "title": "Test server", 69 | "forms": [ 70 | { 71 | "name": "add_user", 72 | "title": "Add user", 73 | "description": "Add a user to the htpasswd file", 74 | "submit_title": "Add user", 75 | "script": "job_add_user.sh", 76 | "fields": [ 77 | {"name": "username", "title": "Username", "type": "string"}, 78 | {"name": "password1", "title": "Password", "type": "password"}, 79 | {"name": "password2", "title": "Repeat password", "type": "password"} 80 | ] 81 | } 82 | ] 83 | } 84 | 85 | The script `job_add_user.sh`: 86 | 87 | #!/bin/sh 88 | 89 | if [ -z "$password1" ]; then 90 | echo "Empty password specified" >&2; exit 1 91 | fi 92 | if [ "$password1" != "$password2" ]; then 93 | echo "Passwords do not match" >&2; exit 1 94 | fi 95 | 96 | htpasswd -s -b .htpasswd "$username" "$password1" || exit $? 97 | 98 | echo "User created or password updated" 99 | 100 | Set some rights and create the initial `htpasswd` file: 101 | 102 | $ chmod 755 job_add_user.sh 103 | $ touch .htpasswd 104 | 105 | We can now start ScriptForm to start serving the form over HTTP. By default it 106 | starts as a daemon, so we specify the `-f` option to start it in the foreground 107 | instead. We also specify the port, even though 8081 is already the default. 108 | 109 | $ scriptform -f -p8081 ./test_server.json 110 | 111 | The user is presented with the following form: 112 | 113 | ![](https://raw.githubusercontent.com/fboender/scriptform/master/doc/screenshots/form.png) 114 | 115 | When submitting the form, the results are displayed. 116 | 117 | ![](https://raw.githubusercontent.com/fboender/scriptform/master/doc/screenshots/result.png) 118 | 119 | For more examples, see the [examples directory](https://github.com/fboender/scriptform/tree/master/examples) 120 | 121 | For more screenshots, see the [screenshots Wiki page](https://github.com/fboender/scriptform/wiki/Screenshots) 122 | 123 | ## Installation 124 | 125 | ### Requirements 126 | 127 | The binary release of Scriptform requires: 128 | 129 | * Glibc v2.2.5+ 130 | 131 | The python release of Scriptform requires: 132 | 133 | * Python 3.6+ 134 | 135 | No other libraries are required. 136 | 137 | ### Installation 138 | 139 | Get the package for your operating system from the [Github releases page](https://github.com/fboender/scriptform/releases). 140 | 141 | The **binary release** should work on most modern systems: 142 | 143 | Get the latest `*-bin64.tar.gz` release from the [releases page](https://github.com/fboender/scriptform/releases). 144 | 145 | Unpack it and copy it to some location in your `PATH`: 146 | 147 | tar -vxzf scriptform-*-bin64.tar.gz 148 | sudo cp scriptform-*-bin64/scriptform /usr/local/bin 149 | 150 | For **Debian / Ubuntu** systems: 151 | 152 | sudo dpkg -i scriptform*.deb 153 | 154 | For **Redhat / Centos** systems: 155 | 156 | sudo yum install scriptform*.rpm 157 | 158 | For **Other** systems: 159 | 160 | tar -vxzf scriptform*.tar.gz 161 | cd scriptform* 162 | sudo make install 163 | 164 | ### Configuration 165 | 166 | Scriptform provides init scripts to automatically start Scriptform at boot 167 | time. These are not installed by default. You can find init scripts for 168 | Debian / Ubuntu at `/usr/share/doc/scriptform/scriptform.init.d_debian` and 169 | for Redhat / Centos at `/usr/share/doc/scriptform/scriptform.init.d_debian`. 170 | 171 | **NOTE**: If you use an init script, Scriptform will run as user `root`, which 172 | will cause Scriptform to automatically drop privileges to user `nobody` and 173 | group `nobody` when executing shell scripts. This may cause "permission 174 | denied" problems! See the "Execution security policy" chapter in the User 175 | Manual for more information. 176 | 177 | To install the init script: 178 | 179 | For **Debian / Ubuntu** systems: 180 | 181 | sudo cp /usr/share/doc/scriptform/scriptform.init.d_debian /etc/init.d/scriptform 182 | sudo chmod 755 /etc/init.d/scriptform 183 | sudo update-rc.d scriptform defaults 184 | 185 | Then edit `/etc/init.d/scriptform` and change the `FORM_CONFIG` setting to 186 | point at the form configuration JSON file you'd like to use. 187 | 188 | For **RedHat / Centos** systems: 189 | 190 | sudo cp /usr/share/doc/scriptform/scriptform.init.d_redhat /etc/init.d/scriptform 191 | sudo chmod 755 /etc/init.d/scriptform 192 | sudo chkconfig --add scriptform 193 | sudo chkconfig scriptform on 194 | 195 | Then edit `/etc/init.d/scriptform` and change the `FORM_CONFIG` setting to 196 | point at the form configuration JSON file you'd like to use. 197 | 198 | There's also a **Systemd** unit file, which should work on most systems that 199 | run on systemd: 200 | 201 | sudo cp /usr/share/doc/scriptform/scriptform.service /etc/systemd/system/ 202 | 203 | Then edit `/etc/systemd/system/scriptform.service` and make change the 204 | `FORM_CONFIG` environment variable to point at the form configuration JSON 205 | file you'd like to use. 206 | 207 | 208 | 209 | 210 | ## Usage 211 | 212 | Usage: 213 | 214 | Usage: /usr/bin/scriptform [option] (--start|--stop) 215 | /usr/bin/scriptform --generate-pw 216 | 217 | Options: 218 | --version show program's version number and exit 219 | -h, --help show this help message and exit 220 | -g, --generate-pw Generate password 221 | -p PORT, --port=PORT Port to listen on (default=8081) 222 | -f, --foreground Run in foreground (debugging) 223 | -r, --reload Reload form config on every request (DEV) 224 | --pid-file=PID_FILE Pid file 225 | --log-file=LOG_FILE Log file 226 | --start Start daemon 227 | --stop Stop daemon 228 | 229 | 230 | ScriptForm can run both in daemon mode or in the foreground. In daemon mode, we 231 | can control ScriptForm with the `--start` and `--stop` options. By default it 232 | runs on port 8081, which we can change with the `-p` option. 233 | 234 | $ scriptform -p8081 ./test_server.json 235 | 236 | This puts ScriptForm in the background as a daemon. It creates a PID file and a 237 | log file. 238 | 239 | $ tail scriptform.log 240 | 2015-04-08 07:57:27,160:DAEMON:INFO:Starting 241 | 2015-04-08 07:57:27,161:DAEMON:INFO:PID = 5614 242 | 2015-04-08 07:57:27,162:SCRIPTFORM:INFO:Listening on 0.0.0.0:8081 243 | 244 | In order to stop the daemon: 245 | 246 | $ scriptform --stop 247 | 248 | We can control the location of the PID file and log file with the `--pid-file` 249 | and `--log-file` options. If we don't specify these, ScriptForm will create 250 | them in the local directory. 251 | 252 | To run ScriptForm in the foreground, specify the `-f` option. 253 | 254 | If you're going to use built-in basic authentication, you can generate a 255 | password for your user with the `--generate-pw` option: 256 | 257 | $ scriptform --generate-pw 258 | Password: 259 | Repeat password: 260 | 2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae 261 | 262 | You can paste the generated password into the password field. You can also use 263 | an Apache (or other webserver) frontend for authentication. For more 264 | information, see the User Manual. 265 | 266 | ## Documentation 267 | 268 | The [User Manual](https://github.com/fboender/scriptform/blob/master/doc/MANUAL.md) is the main source for all your documentation 269 | needs: 270 | 271 | * Read the [tutorial](https://github.com/fboender/scriptform/blob/master/doc/MANUAL.md#tutorial) to quickly get aquinted with 272 | Scriptform. 273 | * Read about [form configurations](https://github.com/fboender/scriptform/blob/master/doc/MANUAL.md#form_config) to learn all the 274 | configuration options for Scriptform and your forms. 275 | * The [field types](https://github.com/fboender/scriptform/blob/master/doc/doc/MANUAL.md#field_types) chapter lists all the possible 276 | fields and which options they take. 277 | 278 | ## Security 279 | 280 | ScriptForm is only as secure as you write your scripts. Although form values 281 | are validated before calling scripts, many possible security problems should be 282 | taken into consideration. As such, you should *never* expose ScriptForm to the 283 | public internet. Its intended end users should be people you trust at least to 284 | a certain degree. 285 | 286 | ## License 287 | 288 | ScriptForm is released under the following license: 289 | 290 | GPLv3 license. 291 | 292 | This program is free software: you can redistribute it and/or modify 293 | it under the terms of the GNU General Public License as published by 294 | the Free Software Foundation, either version 3 of the License, or 295 | (at your option) any later version. 296 | 297 | This program is distributed in the hope that it will be useful, 298 | but WITHOUT ANY WARRANTY; without even the implied warranty of 299 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 300 | GNU General Public License for more details. 301 | 302 | You should have received a copy of the GNU General Public License 303 | along with this program. If not, see . 304 | -------------------------------------------------------------------------------- /build.sla: -------------------------------------------------------------------------------- 1 | # 2 | # This is a script containing functions that are used as build rules. You can 3 | # use the Simple Little Automator (https://github.com/fboender/sla.git) to run 4 | # these rules, or you can run them directly in your shell: 5 | # 6 | # $ bash -c ". build.sla && test" 7 | # 8 | 9 | PROG="scriptform" 10 | 11 | test () { 12 | # Run tests 13 | # Runs unit, integration, linting and code quality tests. 14 | ROOTDIR="$(pwd)" 15 | 16 | # Unit / integration tests 17 | cd test && /usr/bin/env python3 ./test.py 18 | 19 | # Code quality linting (flake8) 20 | cd $ROOTDIR 21 | # E402 == module level import not at top of file 22 | cd src && flake8 --extend-ignore=E402 *.py || true 23 | 24 | # Code quality linting (pylint) 25 | cd $ROOTDIR 26 | cd src && pylint --reports=n -dR -d subprocess-popen-preexec-fn -d invalid-name -d star-args -d no-member *.py || true 27 | cd $ROOTDIR 28 | } 29 | 30 | clean () { 31 | # Clean the repo of artifacts 32 | rm -rf $PROG.spec 33 | rm -rf *.tar.gz 34 | rm -rf *.zip 35 | rm -rf *.deb 36 | rm -rf rel_deb 37 | rm -rf rel_bin 38 | rm -rf scriptform-* 39 | rm -rf doc/manual.html 40 | rm -rf doc/MANUAL.html 41 | rm -rf examples/megacorp_acc/megacorp.db 42 | rm -rf examples/megacorp_acc/.coverage 43 | rm -rf examples/megacorp_acc/htmlcov 44 | find ./ -name "*.log" -delete 45 | find ./ -name "*.pyc" -delete 46 | rm -f test/data.csv 47 | rm -f test/data.raw 48 | rm -f test/.coverage 49 | rm -rf test/htmlcov 50 | rm -rf dist/ 51 | rm -rf build 52 | } 53 | 54 | doc () { 55 | # Generate documentation 56 | cat doc/header.html > doc/MANUAL.html 57 | markdown_py doc/MANUAL.md >> doc/MANUAL.html 58 | cat doc/footer.html >> doc/MANUAL.html 59 | 60 | cat doc/header.html > README.html 61 | markdown_py README.md >> README.html 62 | cat doc/footer.html >> README.html 63 | T_DOC_BUILT=1 64 | } 65 | 66 | _release_check() { 67 | # Verify and prepare for release 68 | 69 | # Only run this rule once 70 | if [ -z "$RELEASE_CHECK_DONE" ]; then 71 | RELEASE_CHECK_DONE=1 72 | 73 | # Prepare project for release 74 | clean 75 | doc 76 | 77 | # Check that REL_VERSION is set 78 | if [ ! -z "$1" ]; then 79 | REL_VERSION="$1" 80 | shift 81 | else 82 | echo "REL_VERSION not set. Aborting" >&2 83 | exit 1 84 | fi 85 | fi 86 | } 87 | 88 | release_src () { 89 | # Build source (tar.gz) release 90 | # Usage: sla release_src 91 | # 92 | # Example: 93 | # sla release_src 9.99 94 | _release_check "$*" 95 | 96 | # Prepare source 97 | rm -rf "$PROG-$REL_VERSION" 98 | mkdir "$PROG-$REL_VERSION" 99 | cp src/*.py "$PROG-$REL_VERSION/" 100 | mv "$PROG-$REL_VERSION/scriptform.py" "$PROG-$REL_VERSION/$PROG" 101 | cp LICENSE "$PROG-$REL_VERSION/" 102 | cp README.md "$PROG-$REL_VERSION/" 103 | cp contrib/release_Makefile "$PROG-$REL_VERSION/Makefile" 104 | cp doc/MANUAL.html "$PROG-$REL_VERSION/MANUAL.html" 105 | 106 | # Bump version numbers 107 | find "$PROG-$REL_VERSION/" -type f -print0 | xargs -0 sed -i "s/%%VERSION%%/$REL_VERSION/g" 108 | 109 | # Create archives 110 | zip -q -r "$PROG-$REL_VERSION.zip" "$PROG-$REL_VERSION" 111 | tar -czf "$PROG-$REL_VERSION.tar.gz" "$PROG-$REL_VERSION" 112 | } 113 | 114 | release_deb () { 115 | # Build deb release 116 | # Usage: sla release_deb 117 | # 118 | # Example: 119 | # sla release_deb 9.99 120 | _release_check "$*" 121 | 122 | if [ -z "$RELEASE_DEB_DONE" ]; then 123 | mkdir -p "rel_deb/usr/bin" 124 | mkdir -p "rel_deb/usr/lib/$PROG" 125 | mkdir -p "rel_deb/usr/share/doc/$PROG" 126 | mkdir -p "rel_deb/usr/share/man/man1" 127 | 128 | # Copy the source to the release directory structure. 129 | cp LICENSE "rel_deb/usr/share/doc/$PROG" 130 | cp README.md "rel_deb/usr/share/doc/$PROG" 131 | cp doc/MANUAL.md "rel_deb/usr/share/doc/$PROG" 132 | cp README.html "rel_deb/usr/share/doc/$PROG" 133 | cp doc/MANUAL.html "rel_deb/usr/share/doc/$PROG" 134 | cp -ar examples "rel_deb/usr/share/doc/$PROG" 135 | cp src/*.py "rel_deb/usr/lib/$PROG/" 136 | ln -s "/usr/lib/$PROG/scriptform.py" "rel_deb/usr/bin/$PROG" 137 | 138 | cp contrib/scriptform.init.d_debian "rel_deb/usr/share/doc/$PROG" 139 | cp contrib/scriptform.init.d_redhat "rel_deb/usr/share/doc/$PROG" 140 | cp contrib/scriptform.service "rel_deb/usr/share/doc/$PROG" 141 | cp -ar contrib/debian/DEBIAN "rel_deb/" 142 | 143 | # Bump version numbers 144 | find rel_deb/ -type f -print0 | xargs -0 sed -i "s/%%VERSION%%/$REL_VERSION/g" 145 | 146 | # Create debian pacakge 147 | fakeroot dpkg-deb --build rel_deb > /dev/null 148 | mv rel_deb.deb "$PROG-$REL_VERSION.deb" 149 | 150 | # Cleanup 151 | rm -rf rel_deb 152 | rm -rf "$PROG-$REL_VERSION" 153 | 154 | RELEASE_DEB_DONE=1 155 | fi 156 | } 157 | 158 | release_rpm () { 159 | # Build rpm release 160 | # Usage: sla release_rpm 161 | # 162 | # Example: 163 | # sla release_rpm 9.99 164 | _release_check "$*" 165 | release_deb 166 | 167 | alien -r -g -v scriptform-$REL_VERSION.deb 168 | sed -i 's#%dir "/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 169 | sed -i 's#%dir "/usr/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 170 | sed -i 's#%dir "/usr/bin/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 171 | sed -i 's#%dir "/usr/lib/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 172 | sed -i 's#%dir "/usr/share/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 173 | sed -i 's#%dir "/usr/share/doc/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 174 | sed -i 's#%dir "/usr/share/man/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 175 | sed -i 's#%dir "/usr/share/man/man1/"##' scriptform-$REL_VERSION/scriptform-$REL_VERSION-2.spec 176 | pushd . 177 | cd scriptform-$REL_VERSION/ 178 | FULLPATH="$(pwd)" 179 | rpmbuild --quiet --target=noarch --buildroot "$FULLPATH" -bb scriptform-$REL_VERSION-2.spec 180 | popd 181 | } 182 | 183 | release_bin () { 184 | # Build standalone binary release 185 | # Usage: sla release_bin 186 | # 187 | # Example: 188 | # sla release_bin 9.99 189 | _release_check "$*" 190 | 191 | rm -rf dist/scriptform/ 192 | 193 | # Create copy and bump version numbers 194 | cp -ar src/ rel_bin/ 195 | find rel_bin/ -type f -print0 | xargs -0 sed -i "s/%%VERSION%%/$REL_VERSION/g" 196 | 197 | # Generate binary 198 | pyinstaller --strip --onefile rel_bin/scriptform.py 199 | 200 | # Generate tarball 201 | mv dist $PROG-$REL_VERSION-bin64 202 | cp contrib/scriptform.service $PROG-$REL_VERSION-bin64 203 | tar -czf $PROG-$REL_VERSION-bin64.tar.gz $PROG-$REL_VERSION-bin64 204 | 205 | # Cleanup 206 | rm -rf $PROG-$REL_VERSION-bin64 207 | rm -rf $PROG.spec 208 | rm -rf build 209 | rm -rf rel_bin 210 | } 211 | 212 | release () { 213 | # Build all releases 214 | # Usage: sla release 215 | # 216 | # Builds the debian, rpm packages, source release and standalone binary. 217 | # 218 | # Example: 219 | # sla release 9.99 220 | _release_check "$*" 221 | release_src 222 | release_deb 223 | release_rpm 224 | release_bin 225 | } 226 | -------------------------------------------------------------------------------- /contrib/debian/DEBIAN/conffiles: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/contrib/debian/DEBIAN/conffiles -------------------------------------------------------------------------------- /contrib/debian/DEBIAN/control: -------------------------------------------------------------------------------- 1 | Package: scriptform 2 | Version: %%VERSION%% 3 | Maintainer: Ferry Boender 4 | Section: utils 5 | Priority: optional 6 | Architecture: all 7 | Depends: diffutils 8 | Description: Serve web forms as frontends to scripts. 9 | ScriptForm takes a JSON file which contains form definitions. It then 10 | constructs web forms from this JSON and serves these to users. The user can 11 | select a form and fill it out. When the user submits the form, it is 12 | validated and the associated script or Python callback is called. Data 13 | entered in the form is passed to the script through the environment. 14 | 15 | -------------------------------------------------------------------------------- /contrib/release_Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | cp src/scriptform.py /usr/bin/scriptform 3 | 4 | uninstall: 5 | rm /etc/init.d/scriptform 6 | -------------------------------------------------------------------------------- /contrib/scriptform.init.d_debian: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: scriptform 4 | # Required-Start: $local_fs $remote_fs $syslog 5 | # Required-Stop: $local_fs $remote_fs $syslog 6 | # Should-Start: 7 | # Should-Stop: 8 | # Default-Start: 2 3 4 5 9 | # Default-Stop: 0 1 6 10 | # Short-Description: Serve web form frontends to scripts. 11 | # Description: Webserver daemon that dynamically constructs web forms 12 | # from JSON files and calls scripts on form submits. 13 | ### END INIT INFO 14 | 15 | # Author: Ferry Boender 16 | 17 | # Settings 18 | NAME=scriptform 19 | PIDFILE=/var/run/scriptform.pid 20 | LOGFILE=/var/log/scriptform.log 21 | DAEMON=/usr/bin/scriptform 22 | FORM_CONFIG= 23 | PORT=8081 24 | DAEMON_ARGS="--port $PORT --pid-file $PIDFILE --log-file $LOGFILE $FORM_CONFIG" 25 | 26 | # Define LSB log_* functions. 27 | # Depend on lsb-base (>= 3.0-6) to ensure that this file is present. 28 | . /lib/lsb/init-functions 29 | 30 | # Exit if scriptform isn't installed 31 | if [ \! -x $DAEMON ]; then 32 | log_daemon_msg "$DAEMON not found. Not starting." 33 | log_end_msg 0 34 | exit 0 35 | fi 36 | 37 | # Exit if the form config file hasn' been configured 38 | if [ -z "$FORM_CONFIG" ]; then 39 | log_daemon_msg "No form configuration is configured. Please edit the init file." 40 | log_end_msg 0 41 | exit 0 42 | fi 43 | 44 | # Exit if the form config file can't be found 45 | if [ \! -e $FORM_CONFIG ]; then 46 | log_daemon_msg "Configured form config '$FORM_CONFIG' not found." 47 | log_end_msg 1 48 | exit 1 49 | fi 50 | 51 | # 52 | # Function that starts the daemon/service 53 | # 54 | do_start() 55 | { 56 | # Return 57 | # 0 if daemon has been started 58 | # 1 if daemon was already running 59 | # 2 if daemon could not be started 60 | start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ 61 | || return 1 62 | start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \ 63 | $DAEMON_ARGS \ 64 | || return 2 65 | } 66 | 67 | # 68 | # Function that stops the daemon/service 69 | # 70 | do_stop() 71 | { 72 | # Return 73 | # 0 if daemon has been stopped 74 | # 1 if daemon was already stopped 75 | # 2 if daemon could not be stopped 76 | # other if a failure occurred 77 | start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE 78 | RETVAL="$?" 79 | [ "$RETVAL" = 2 ] && return 2 80 | # Wait for children to finish too if this is a daemon that forks 81 | # and if the daemon is only ever run from this initscript. 82 | # If the above conditions are not satisfied then add some other code 83 | # that waits for the process to drop all resources that could be 84 | # needed by services started subsequently. A last resort is to 85 | # sleep for some time. 86 | start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON 87 | [ "$?" = 2 ] && return 2 88 | # Many daemons don't delete their pidfiles when they exit. 89 | rm -f $PIDFILE 90 | return "$RETVAL" 91 | } 92 | 93 | # 94 | # Function that sends a SIGHUP to the daemon/service 95 | # 96 | do_reload() { 97 | # 98 | # If the daemon can reload its configuration without 99 | # restarting (for example, when it is sent a SIGHUP), 100 | # then implement that here. 101 | # 102 | start-stop-daemon --stop --signal 1 --quiet --pidfile $PIDFILE 103 | return 0 104 | } 105 | 106 | case "$1" in 107 | start) 108 | [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC " "$NAME" 109 | do_start 110 | case "$?" in 111 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 112 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 113 | esac 114 | ;; 115 | stop) 116 | [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" 117 | do_stop 118 | case "$?" in 119 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 120 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 121 | esac 122 | ;; 123 | status) 124 | status_of_proc "$DAEMON" "$NAME" && exit 0 || exit $? 125 | ;; 126 | #reload|force-reload) 127 | # 128 | # If do_reload() is not implemented then leave this commented out 129 | # and leave 'force-reload' as an alias for 'restart'. 130 | # 131 | #log_daemon_msg "Reloading $DESC" "$NAME" 132 | #do_reload 133 | #log_end_msg $? 134 | #;; 135 | restart|force-reload) 136 | # 137 | # If the "reload" option is implemented then remove the 138 | # 'force-reload' alias 139 | # 140 | log_daemon_msg "Restarting $DESC" "$NAME" 141 | do_stop 142 | case "$?" in 143 | 0|1) 144 | do_start 145 | case "$?" in 146 | 0) log_end_msg 0 ;; 147 | 1) log_end_msg 1 ;; # Old process is still running 148 | *) log_end_msg 1 ;; # Failed to start 149 | esac 150 | ;; 151 | *) 152 | # Failed to stop 153 | log_end_msg 1 154 | ;; 155 | esac 156 | ;; 157 | *) 158 | #echo "Usage: $SCRIPTNAME {start|stop|restart|reload|force-reload}" >&2 159 | echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 160 | exit 3 161 | ;; 162 | esac 163 | 164 | : 165 | -------------------------------------------------------------------------------- /contrib/scriptform.init.d_redhat: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # scriptform Scriptform server 4 | # 5 | # chkconfig: 345 70 30 6 | # description: Standalone web form front-end to scripts. 7 | # processname: scriptform 8 | 9 | # Source function library. 10 | . /etc/init.d/functions 11 | 12 | RETVAL=0 13 | prog="scriptform" 14 | 15 | # Settings. Change these, especially FORM_CONFIG 16 | NAME=scriptform 17 | PIDFILE=/var/run/scriptform.pid 18 | LOGFILE=/var/log/scriptform.log 19 | DAEMON=/usr/bin/scriptform 20 | FORM_CONFIG= 21 | PORT=8081 22 | DAEMON_ARGS="--port $PORT --pid-file $PIDFILE --log-file $LOGFILE $FORM_CONFIG" 23 | 24 | start() { 25 | echo -n "Starting $prog: " 26 | $DAEMON $DAEMON_ARGS 27 | RETVAL=$? 28 | echo $RETVAL 29 | echo 30 | return $RETVAL 31 | } 32 | 33 | stop() { 34 | echo -n "Shutting down $prog: " 35 | $DAEMON --stop $DAEMON_ARGS && success || failure 36 | RETVAL=$? 37 | echo $RETVAL 38 | echo 39 | return $RETVAL 40 | } 41 | 42 | status() { 43 | echo -n "Checking $prog status: " 44 | [ \! -e $PIDFILE ] 45 | RETVAL=$? 46 | return $RETVAL 47 | } 48 | 49 | case "$1" in 50 | start) 51 | start 52 | ;; 53 | stop) 54 | stop 55 | ;; 56 | status) 57 | status 58 | ;; 59 | restart) 60 | stop 61 | start 62 | ;; 63 | *) 64 | echo "Usage: $prog {start|stop|status|restart}" 65 | exit 1 66 | ;; 67 | esac 68 | exit $RETVAL 69 | -------------------------------------------------------------------------------- /contrib/scriptform.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Scriptform 3 | After=network.target 4 | 5 | [Service] 6 | Environment=FORM_CONFIG=/home/fboender/Projects/fboender/scriptform/examples/megacorp_acc/megacorp_acc.json 7 | Environment=LOG_FILE=/var/log/scriptform.log 8 | Environment=PORT=8081 9 | ExecStart=/usr/bin/scriptform --pid-file=/var/run/scriptform.pid --port=${PORT} --log-file=${LOG_FILE} ${FORM_CONFIG} 10 | Type=forking 11 | PIDFile=/var/run/scriptform.pid 12 | 13 | [Install] 14 | WantedBy=default.target 15 | -------------------------------------------------------------------------------- /doc/DEV.md: -------------------------------------------------------------------------------- 1 | # Scriptform Developer guide 2 | 3 | ## Build rules 4 | 5 | Build rules for testing, generating documentation and generating packages are 6 | stored in `build.sla`. These can be run directly from a POSIX compliant shell, 7 | or you can use the [Simple Little Automator](https://github.com/fboender/sla) 8 | for convenience. 9 | 10 | ## Inner workings 11 | 12 | 1. Instantiate a `ScriptForm` class. This takes care of loading the form 13 | config (json) file and provides methods to run the server. 14 | 2. If running as a daemon: 15 | a) Instantiate the `Daemon` class 16 | b) Hook up a callback to shutdown the ScriptForm server 17 | c) Start the daemon. This detaches from the console. 18 | 3. Start the ScriptForm server. This listens on a port for incoming HTTP 19 | connections. 20 | 4. If a request comes in, it is dispatched to the `ScriptFormWebApp` request 21 | handler. `ScriptFormWebApp` inherits from the `webserver.RequestHandler` 22 | class. The `WebAppHandler` determines which method of `ScriptFormWebApp` 23 | the request should be dispatched to. 24 | 5. Depending on the request, a method is called on `ScriptFormWebApp`. These 25 | methods render HTML to as a response. 26 | 6. If a form is submitted, its fields are validated and the script callback is 27 | called. Depending on the output type, the output of the script is either 28 | captured and displayed as HTML to the user or directly streamed to the 29 | browser. 30 | 7. GOTO 4. 31 | 8. Upon receiving an OS signal (kill, etc) the daemon calls the shutdown 32 | callback. 33 | 9. The shutdown callback starts a new thread (otherwise the webserver blocks 34 | until the next request) to stop the server. 35 | 10. The program exits. 36 | -------------------------------------------------------------------------------- /doc/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /doc/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /doc/screenshots/form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/doc/screenshots/form.png -------------------------------------------------------------------------------- /doc/screenshots/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/doc/screenshots/list.png -------------------------------------------------------------------------------- /doc/screenshots/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/doc/screenshots/result.png -------------------------------------------------------------------------------- /examples/auth/README.md: -------------------------------------------------------------------------------- 1 | ScriptForm auth example 2 | ======================= 3 | 4 | This example shows how to authenticate users. Everyone must authenticate. Only 5 | user 'test2' is allowed to see and execute the 'only_some_users' form. 6 | 7 | The credentials are: 8 | 9 | test:secret 10 | test2:password 11 | -------------------------------------------------------------------------------- /examples/auth/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Authorization protected", 3 | "users": { 4 | "test": "2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b", 5 | "test2": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" 6 | }, 7 | "forms": [ 8 | { 9 | "name": "do_nothing", 10 | "title": "Test form", 11 | "description": "All users that logged in should be able to see this.", 12 | "submit_title": "Do nothing", 13 | "script": "job_do_nothing.sh", 14 | "fields": [ 15 | ] 16 | }, 17 | { 18 | "name": "only_some_users", 19 | "title": "Only some users", 20 | "description": "You should only see this if you're user 'test2'", 21 | "submit_title": "Do nothing", 22 | "script": "job_do_nothing.sh", 23 | "allowed_users": ["test2"], 24 | "fields": [ 25 | ] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /examples/auth/job_do_nothing.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "I did nothing very successfully" 4 | -------------------------------------------------------------------------------- /examples/customize/custom.css: -------------------------------------------------------------------------------- 1 | form { 2 | background-color: #F0F0F0; 3 | padding: 20px; 4 | border-radius: 10px; 5 | } 6 | form li.submit input { 7 | background-color: #FF0000; 8 | } 9 | div.result pre { 10 | font-size: 12px; 11 | overflow: scroll; 12 | } 13 | -------------------------------------------------------------------------------- /examples/customize/customize.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Customized forms", 3 | "custom_css": "custom.css", 4 | "forms": [ 5 | { 6 | "name": "custom", 7 | "title": "Customized form", 8 | "description": "This form is customized using global CSS and inline CSS.

The form's background has been made lightgray, the submit button is red and the results are wrapped in a scrolling viewport. This was achieved using a global \"custom_css\": \"custom.css\", form configuration option.

The background of the input box has been turned green. This is done using the style option for the fom field.", 9 | "script": "job_customize.sh", 10 | "fields": [ 11 | { 12 | "name": "background", 13 | "title": "Different background color", 14 | "type": "string", 15 | "style": "background-color: #C0FFC0;", 16 | "classes": "foo bar" 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /examples/customize/job_customize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | env 4 | -------------------------------------------------------------------------------- /examples/dynamic_forms/README.md: -------------------------------------------------------------------------------- 1 | ScriptForm dynamic forms 2 | ======================== 3 | 4 | This example shows how to create dynamic forms where parts of the form are 5 | generated by external scripts. 6 | -------------------------------------------------------------------------------- /examples/dynamic_forms/dynamic_forms.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Dynamic forms", 3 | "forms": [ 4 | { 5 | "name": "dyn_options", 6 | "title": "Dynamic options", 7 | "description": "The values for the target database are dynamically read from a script.", 8 | "submit_title": "Import", 9 | "script": "job_import.sh", 10 | "fields": [ 11 | { 12 | "name": "target_db", 13 | "title": "Target database to import to", 14 | "type": "radio", 15 | "options_from": "form_dyn_options_target_db.sh" 16 | }, 17 | { 18 | "name": "sql_file", 19 | "title": "SQL file", 20 | "type": "file" 21 | } 22 | ] 23 | }, 24 | { 25 | "name": "dyn_fields", 26 | "title": "Dynamic fileds", 27 | "description": "All the fields in this form are dynamically read from a script.", 28 | "submit_title": "Import", 29 | "script": "job_import.sh", 30 | "fields_from": "form_dyn_fields.sh" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /examples/dynamic_forms/form_dyn_fields.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | OPTIONS=$(cat <<'END_HEREDOC' 4 | [ 5 | ["test", "Test DB"], 6 | ["acc", "Acc DB"], 7 | ["prod", "Prod DB"] 8 | ] 9 | END_HEREDOC 10 | ) 11 | 12 | cat << END_TEXT 13 | [ 14 | { 15 | "name": "target_db", 16 | "title": "Database to import to", 17 | "type": "radio", 18 | "options": $OPTIONS 19 | }, 20 | { 21 | "name": "sql_file", 22 | "title": "SQL file", 23 | "type": "file" 24 | } 25 | ] 26 | END_TEXT 27 | -------------------------------------------------------------------------------- /examples/dynamic_forms/form_dyn_options_target_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cat << END_TEXT 4 | [ 5 | ["test", "Test DB"], 6 | ["acc", "Acc DB"], 7 | ["prod", "Prod DB"] 8 | ] 9 | END_TEXT 10 | -------------------------------------------------------------------------------- /examples/dynamic_forms/job_import.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | MYSQL_DEFAULTS_FILE="my.cnf" 4 | MYSQL="mysql --defaults-file=$MYSQL_DEFAULTS_FILE" 5 | 6 | echo "This is what would be executed if this wasn't a fake script:" 7 | echo 8 | echo "echo 'DROP DATABASE $target_db' | $MYSQL" 9 | echo "$MYSQL ${target_db} < ${sql_file}" 10 | -------------------------------------------------------------------------------- /examples/megacorp_acc/README.md: -------------------------------------------------------------------------------- 1 | Extensive example of all kinds of jobs for the fictional Acceptance environment for Megacorp. 2 | 3 | Usernames: 4 | 5 | HtAuth 6 | 7 | * username: admin 8 | * password: password 9 | 10 | Restart acceptance services password: 11 | 12 | * password: 123foobar 13 | 14 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_clean_database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$source_sql" = "empty" ]; then 4 | echo "Loading empty database" 5 | rm megacorp.db 6 | sqlite3 megacorp.db < megacorp_empty.sql && echo "Succesfully loaded" 7 | else 8 | echo "Not Implemented" 9 | fi 10 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_download_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | FILESIZE=$(stat -c "%s" megacorp.db) 4 | cat << EOF 5 | HTTP/1.0 200 Ok 6 | Content-Type: application/octet-stream 7 | Content-Disposition: attachment; filename=megacorp.db 8 | Content-Length: $FILESIZE 9 | 10 | EOF 11 | cat megacorp.db 12 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_enable_firewall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # FIXME: Validate ip address 4 | 5 | if [ "$network" = "intra" ]; then 6 | NETWORK="192.168.1.0/24" 7 | elif [ "$network" = "machine" ]; then 8 | NETWORK="192.168.1.12" 9 | else 10 | echo "Invalid network >&2" 11 | exit 1 12 | fi 13 | 14 | echo "iptables -A INPUT -p tcp --source $ip_address --dest $NETWORK -j ACCEPT" 15 | echo "echo \"iptables -A INPUT -p tcp --source $ip_address --dest $NETWORK -j ACCEPT\" | at now + $expire_days days" 16 | echo 17 | echo "Comment: ${comment}" 18 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_import_employees.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$csv_file" ]; then 4 | echo "No file specified" >&2 5 | exit 1 6 | fi 7 | 8 | { 9 | echo ".separator ," 10 | echo ".import $csv_file employee" 11 | } | sqlite3 megacorp.db 12 | if [ $? -eq 0 ]; then 13 | echo "Succesfully loaded employees" 14 | else 15 | echo "Failed to load employees. Maybe you should clean the database first?" 16 | fi 17 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_list_employees.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "" 4 | { 5 | echo ".mode html" 6 | echo "SELECT * FROM employee;" 7 | } | sqlite3 megacorp.db || exit 1 8 | echo "
" 9 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_restart_acc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$passwd" != "123foobar" ]; then 4 | echo "Invalid password" >&2 5 | exit 1 6 | fi 7 | 8 | echo "RESTARTING" 9 | 10 | if [ $no_db = "on" ]; then 11 | echo "NOT RESTARTING DATABASE" 12 | fi 13 | 14 | sleep 1 15 | 16 | echo "RESTARTED" 17 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_signup_step1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cat << EOF 4 | HTTP/1.1 301 Moved Permanently 5 | Location: form?form_name=signup_step_2&first_name=$first_name&last_name=$last_name&email_address=$email_address 6 | Content-Type: text/html 7 | Content-Length: 174 8 | 9 | 10 | 11 | Redir 12 | 13 | 14 | 15 | 16 | EOF 17 | -------------------------------------------------------------------------------- /examples/megacorp_acc/job_signup_step2.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Signed up '$first_name $last_name <$email_address>' for the newsletter.\n" 4 | 5 | echo "The following topics have been set:\n" 6 | 7 | echo " - Company news: $check_company" 8 | echo " - Products: $check_products" 9 | -------------------------------------------------------------------------------- /examples/megacorp_acc/megacorp_acc.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "MegaCorp acceptance jobs", 3 | "users": { 4 | "jjohnson": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", 5 | "admin": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" 6 | }, 7 | "forms": [ 8 | { 9 | "name": "signup_step_1", 10 | "title": "Newsletter Sign up", 11 | "description": "Test sign up for the newsletter", 12 | "script": "job_signup_step1.sh", 13 | "output": "raw", 14 | "fields": [ 15 | { 16 | "name": "first_name", 17 | "title": "First name", 18 | "type": "string" 19 | }, 20 | { 21 | "name": "last_name", 22 | "title": "Last name", 23 | "type": "string" 24 | }, 25 | { 26 | "name": "birthdate", 27 | "title": "Birthdate", 28 | "type": "date", 29 | "min": "1900-01-01", 30 | "max": "2015-01-01", 31 | "default_value": "1980-02-05" 32 | }, 33 | { 34 | "name": "email_address", 35 | "title": "What's your email address?", 36 | "type": "string", 37 | "style": "width: 300px;" 38 | } 39 | ] 40 | }, 41 | { 42 | "name": "signup_step_2", 43 | "hidden": true, 44 | "title": "Sign up: Your subscriptions", 45 | "description": "Please check which topics you are interested in", 46 | "script": "job_signup_step2.sh", 47 | "fields": [ 48 | { 49 | "name": "check_company", 50 | "title": "Megacorp Company news", 51 | "type": "checkbox" 52 | }, 53 | { 54 | "name": "check_products", 55 | "title": "Product news", 56 | "type": "checkbox", 57 | "checked": true 58 | }, 59 | { 60 | "name": "first_name", 61 | "title": "First name", 62 | "type": "string", 63 | "hidden": true 64 | }, 65 | { 66 | "name": "last_name", 67 | "title": "Last name", 68 | "type": "string", 69 | "hidden": true 70 | }, 71 | { 72 | "name": "birthdate", 73 | "title": "Birthdate", 74 | "type": "date", 75 | "min": "1900-01-01", 76 | "max": "2015-01-01", 77 | "default_value": "1980-02-05", 78 | "hidden": true 79 | }, 80 | { 81 | "name": "email_address", 82 | "title": "What's your email address?", 83 | "type": "string", 84 | "style": "width: 300px;", 85 | "hidden": true 86 | } 87 | ] 88 | }, 89 | { 90 | "name": "clean_database", 91 | "title": "Load clean database", 92 | "description": "Recreate the acceptance database from scratch. This deletes all the information in the database", 93 | "submit_title": "Run", 94 | "script": "job_clean_database.sh", 95 | "fields": [ 96 | { 97 | "name": "source_sql", 98 | "title": "Database type", 99 | "type": "select", 100 | "options": [ 101 | ["empty", "Empty database"], 102 | ["dev", "Development test database"], 103 | ["ua", "Acceptance database"] 104 | ] 105 | } 106 | ] 107 | }, 108 | { 109 | "name": "import_employees", 110 | "title": "Import employee data from CSV", 111 | "description": "Load a CSV with employee data into the database. The employee table MUST be empty.", 112 | "submit_title": "Import CSV", 113 | "script": "job_import_employees.sh", 114 | "fields": [ 115 | { 116 | "name": "csv_file", 117 | "title": "CSV file", 118 | "type": "file" 119 | } 120 | ] 121 | }, 122 | { 123 | "name": "list_employees", 124 | "title": "List employees", 125 | "description": "List the employees currently in the database", 126 | "submit_title": "List", 127 | "script": "job_list_employees.sh", 128 | "output": "html", 129 | "fields": [ 130 | ] 131 | }, 132 | { 133 | "name": "download_db", 134 | "title": "Download database", 135 | "description": "Download the full binary database", 136 | "submit_title": "Download", 137 | "script": "job_download_db.sh", 138 | "output": "raw", 139 | "fields": [ 140 | ] 141 | }, 142 | { 143 | "name": "restart_services", 144 | "title": "Restart Acceptance services", 145 | "description": "Restarts the acceptance services (web, db, cache). Consult with Karl Karlsön first!", 146 | "submit_title": "Restart", 147 | "script": "job_restart_acc.sh", 148 | "fields": [ 149 | { 150 | "name": "passwd", 151 | "title": "Karl Karlsön gave you a password. What is it?", 152 | "type": "password", 153 | "required": true 154 | }, 155 | { 156 | "name": "no_db", 157 | "title": "Do not restart the database", 158 | "type": "checkbox" 159 | } 160 | ] 161 | }, 162 | { 163 | "name": "enable_firewall", 164 | "allowed_users": ["admin"], 165 | "title": "Enable firewall", 166 | "description": "Enable access to the acceptance environment from the entered IP", 167 | "submit_title": "Enable access", 168 | "script": "job_enable_firewall.sh", 169 | "fields": [ 170 | { 171 | "name": "ip_address", 172 | "title": "From IP Address", 173 | "type": "string", 174 | "required": true, 175 | "default_value": "192.168.4.", 176 | "min_length": 7, 177 | "size": 15 178 | }, 179 | { 180 | "name": "expire_days", 181 | "title": "Expire (days)", 182 | "type": "integer", 183 | "max": 31, 184 | "min": 2, 185 | "default_value": 7 186 | }, 187 | { 188 | "name": "network", 189 | "title": "To which network", 190 | "type": "radio", 191 | "options": [ 192 | ["intra", "Whole intranet"], 193 | ["machine", "Acceptance machine"] 194 | ] 195 | }, 196 | { 197 | "name": "comment", 198 | "title": "Comment", 199 | "type": "text", 200 | "rows": 10, 201 | "cols": 80 202 | } 203 | ] 204 | } 205 | ] 206 | } 207 | -------------------------------------------------------------------------------- /examples/megacorp_acc/megacorp_employees.csv: -------------------------------------------------------------------------------- 1 | 1, JJO, John, Johnson 2 | 2, KKA, Karl, Karlsön 3 | 3, PPE, Pete, Peterson 4 | -------------------------------------------------------------------------------- /examples/megacorp_acc/megacorp_empty.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE employee ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | code TEXT, 4 | firstname TEXT, 5 | lastname TEXT 6 | ); 7 | -------------------------------------------------------------------------------- /examples/output_types/README.md: -------------------------------------------------------------------------------- 1 | ScriptForm output example 2 | ========================= 3 | 4 | Output tpyes determine how Scriptform handles the output from a callback. The 5 | options are: `escaped` (default), `html` and `raw`. The `escaped` option wraps 6 | the output of the callback in PRE tags and escapes any HTML entities. The 7 | `html` option doesn't, which lets the script include HTML formatting in the 8 | output. The `raw` option directly streams the output of the script to the 9 | browser. This allows you to stream images, binary files, etc directly to the 10 | browser. The script should include a full HTTP response including appropriate 11 | headers. 12 | -------------------------------------------------------------------------------- /examples/output_types/job_large_bin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | FILESIZE=$(expr 1024 \* 1000 \* 100) 4 | cat << EOF 5 | HTTP/1.0 200 Ok 6 | Content-Type: application/octet-stream 7 | Content-Disposition: attachment; filename=large_file.dat 8 | Content-Length: $FILESIZE 9 | 10 | EOF 11 | dd if=/dev/urandom bs=1024 count=100000 12 | -------------------------------------------------------------------------------- /examples/output_types/job_show_html.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Here's some bold text. The output of ls:
"
4 | 
5 | ls
6 | 
7 | echo '

A link:

Google.com' 8 | -------------------------------------------------------------------------------- /examples/output_types/job_show_image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cat << EOF 4 | HTTP/1.0 200 Ok 5 | Content-Type: image/jpeg 6 | 7 | EOF 8 | cat test.jpg 9 | -------------------------------------------------------------------------------- /examples/output_types/output.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Output type callback examples", 3 | "forms": [ 4 | { 5 | "name": "show_image", 6 | "title": "Show an image", 7 | "description": "Shows you an image. This is a 'raw' output type where the script writes a complete HTTP response to stdout, which is directly streamed to the browser by scriptform.", 8 | "submit_title": "Show", 9 | "script": "job_show_image.sh", 10 | "output": "raw", 11 | "fields": {} 12 | }, 13 | { 14 | "name": "large_bin", 15 | "title": "Download large binary file", 16 | "description": "Download a large (100mb) binary file. This demonstrated streaming directly to the client of large files.", 17 | "submit_title": "Download", 18 | "script": "job_large_bin.sh", 19 | "output": "raw", 20 | "fields": {} 21 | }, 22 | { 23 | "name": "some_html", 24 | "title": "Show some HTML", 25 | "description": "This is the 'html' output type, which allows HTML in the output of scripts. This can be useful to refer to another form after this form is completed, for instance.", 26 | "submit_title": "Show HTML", 27 | "script": "job_show_html.sh", 28 | "output": "html", 29 | "fields": {} 30 | }, 31 | { 32 | "name": "escaped", 33 | "title": "Escaped contents (default)", 34 | "description": "This is the 'escaped' output type. It is the default. The HTML entities in the output are escaped properly, and is wrapped in PRE elements.", 35 | "submit_title": "Show HTML", 36 | "script": "job_show_html.sh", 37 | "output": "escaped", 38 | "fields": {} 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /examples/output_types/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/examples/output_types/test.jpg -------------------------------------------------------------------------------- /examples/run_as/README.md: -------------------------------------------------------------------------------- 1 | ScriptForm test example 2 | ========================= 3 | 4 | This test example shows the usage of the `run_as` functionality. If we specify a `run_as` field in a form like so: 5 | 6 | 7 | "forms": [ 8 | { 9 | "name": "run_as", 10 | "title": "Run as...", 11 | "description": "", 12 | "submit_title": "Run", 13 | "run_as": "man", 14 | "script": "job_run_as.py", 15 | "fields": [] 16 | } 17 | ] 18 | 19 | Scriptform will try to run the script as that user (in this case: `man`). This 20 | requires Scriptform to be running as root. 21 | 22 | If no `run_as` is given in a script, Scriptform will execute scripts as the 23 | current user (the one running Scriptform). If, however, Scriptform is being run 24 | as root and you don't specify a `run_as` user, the scripts will run as user 25 | `nobody` for security considerations! 26 | -------------------------------------------------------------------------------- /examples/run_as/job_run_as.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import pwd 5 | import grp 6 | 7 | pw = pwd.getpwuid(os.getuid()) 8 | gr = grp.getgrgid(pw.pw_gid) 9 | groups = [g.gr_gid for g in grp.getgrall() if pw.pw_name in g.gr_mem] 10 | priv_esc = True 11 | try: 12 | os.seteuid(0) 13 | except OSError: 14 | priv_esc = False 15 | 16 | print """Running as: 17 | 18 | uid = {0} 19 | gid = {1} 20 | groups = {2}""".format(pw.pw_uid, gr.gr_gid, str(groups)) 21 | 22 | -------------------------------------------------------------------------------- /examples/run_as/run_as.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Run as", 3 | "forms": [ 4 | { 5 | "name": "run_as_man", 6 | "title": "Run as user 'man' (if we're root)", 7 | "description": "", 8 | "submit_title": "Run", 9 | "run_as": "man", 10 | "script": "/tmp/test/job_run_as.py", 11 | "fields": [ 12 | ] 13 | }, 14 | { 15 | "name": "run_as_nobody", 16 | "title": "Run as default user ('nobody if we're root)", 17 | "description": "", 18 | "submit_title": "Run", 19 | "script": "/tmp/test/job_run_as.py", 20 | "fields": [ 21 | ] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | ScriptForm simple example 2 | ========================= 3 | 4 | This example shows how to create two simple forms with shell scripts as 5 | backends. 6 | -------------------------------------------------------------------------------- /examples/simple/htpasswd: -------------------------------------------------------------------------------- 1 | a:{SHA}qZk+NkcGgWq6PiVxeFDCbJzQ2J0= 2 | fb:{SHA}MW7uIgOKtXmGLGk4ZKZl/cYBAjw= 3 | DLKFj:{SHA}qZk+NkcGgWq6PiVxeFDCbJzQ2J0= 4 | -------------------------------------------------------------------------------- /examples/simple/job_add_user.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | HTPASSWD=htpasswd 4 | 5 | err() { 6 | echo $* >&2 7 | exit 1 8 | } 9 | 10 | if [ -z "$password1" ]; then 11 | err "Empty password specified" 12 | fi 13 | if [ "$password1" != "$password2" ]; then 14 | err "Passwords do not match." 15 | fi 16 | 17 | if [ $(egrep "^$username:" $HTPASSWD) ]; then 18 | UPDATE=1 19 | else 20 | UPDATE=0 21 | fi 22 | 23 | htpasswd -s -b $HTPASSWD "$username" "$password1" || exit $? 24 | 25 | if [ "$UPDATE" -eq 1 ]; then 26 | echo "User password updated" 27 | else 28 | echo "User created" 29 | fi 30 | -------------------------------------------------------------------------------- /examples/simple/job_import.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | MYSQL_DEFAULTS_FILE="my.cnf" 5 | MYSQL="mysql --defaults-file=$MYSQL_DEFAULTS_FILE" 6 | 7 | echo "This is what would be executed if this wasn't a fake script:" 8 | echo 9 | echo " echo 'DROP DATABASE $target_db' | $MYSQL" 10 | echo " $MYSQL ${target_db} < ${sql_file}" 11 | 12 | echo 13 | echo "The uploaded file was $(stat --printf="%s" $sql_file) bytes" 14 | echo "The (binary) md5 hash of the uploaded file is: $(md5sum -b $sql_file | cut -d " " -f1)" 15 | -------------------------------------------------------------------------------- /examples/simple/simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test server", 3 | "forms": [ 4 | { 5 | "name": "import", 6 | "title": "Import data", 7 | "description": "Import SQL into a database", 8 | "submit_title": "Import", 9 | "script": "job_import.sh", 10 | "fields": [ 11 | { 12 | "name": "target_db", 13 | "title": "Database to import to", 14 | "type": "select", 15 | "options": [ 16 | ["devtest", "Dev Test db"], 17 | ["prodtest", "Prod Test db"] 18 | ] 19 | }, 20 | { 21 | "name": "sql_file", 22 | "title": "SQL file", 23 | "type": "file" 24 | } 25 | ] 26 | }, 27 | { 28 | "name": "add_user", 29 | "title": "Add user", 30 | "description": "Add a user to the htaccess file or change their password", 31 | "submit_title": "Add user", 32 | "script": "job_add_user.sh", 33 | "fields": [ 34 | { 35 | "name": "username", 36 | "title": "Username", 37 | "type": "string" 38 | }, 39 | { 40 | "name": "password1", 41 | "title": "Password", 42 | "type": "password" 43 | }, 44 | { 45 | "name": "password2", 46 | "title": "Password (Repear)", 47 | "type": "password" 48 | } 49 | ] 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /examples/static_serve/job_serve.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cat < 5 | ENDOFTEXT 6 | -------------------------------------------------------------------------------- /examples/static_serve/static/ssh_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/examples/static_serve/static/ssh_server.png -------------------------------------------------------------------------------- /examples/static_serve/static_serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Static serve", 3 | "static_dir": "static", 4 | "forms": [ 5 | { 6 | "name": "static_serve", 7 | "title": "Serve static files", 8 | "description": "This example has a script that serves a HTML page. The HTML page refers to some static files that are served by Scriptform", 9 | "submit_title": "Serve", 10 | "script": "job_serve.sh", 11 | "output": "html", 12 | "fields": [] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /examples/tutorial/job_helloworld.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | name = os.environ['name'] 6 | if not name: 7 | name = "world" 8 | 9 | print "Hello, {0}!".format(name) 10 | -------------------------------------------------------------------------------- /examples/tutorial/job_helloworld.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$name" ]; then 4 | name="world" 5 | fi 6 | 7 | echo "Hello, $name!" 8 | -------------------------------------------------------------------------------- /examples/tutorial/job_sysinfo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | HOSTNAME=$(hostname -f) 4 | MEM=$(free -h) 5 | DISK=$(df -h) 6 | 7 | cat << END_OF_TEXT 8 | Hostname 9 | ======== 10 | 11 | $HOSTNAME 12 | 13 | 14 | Memory 15 | ====== 16 | 17 | $MEM 18 | 19 | 20 | Disk 21 | ==== 22 | 23 | $DISK 24 | END_OF_TEXT 25 | -------------------------------------------------------------------------------- /examples/tutorial/job_sysinfo_output.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | HOSTNAME=$(hostname -f) 4 | MEM=$(free -h) 5 | DISK=$(df -h) 6 | 7 | cat << END_OF_TEXT 8 |

Hostname

9 |
$HOSTNAME
10 | 11 |

Memory

12 |
$MEM
13 | 14 |

Disk

15 |
$DISK
16 | END_OF_TEXT 17 | -------------------------------------------------------------------------------- /examples/tutorial/job_upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$name" ]; then 4 | name="stranger" 5 | fi 6 | echo "Hello, $name!" 7 | 8 | if [ -z "$upload" ]; then 9 | echo "Looks like you didn't upload a file!" 10 | else 11 | FILE_SIZE=$(wc -c $upload | cut -d " " -f1) 12 | echo "The size in bytes of $upload__name is $FILE_SIZE" 13 | fi 14 | 15 | -------------------------------------------------------------------------------- /examples/tutorial/tutorial.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tutorial", 3 | "forms": [ 4 | { 5 | "name": "System information", 6 | "title": "System information", 7 | "description": "Show information about the operating system", 8 | "script": "job_sysinfo.sh", 9 | "fields": [] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /examples/tutorial/tutorial_fields.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tutorial step 3", 3 | "forms": [ 4 | { 5 | "name": "hello_world", 6 | "title": "Hello, world!", 7 | "description": "Greetings", 8 | "script": "job_helloworld.py", 9 | "fields": [ 10 | { 11 | "name": "name", 12 | "title": "Name", 13 | "type": "string" 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /examples/tutorial/tutorial_output.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tutorial", 3 | "forms": [ 4 | { 5 | "name": "System information", 6 | "title": "System information", 7 | "description": "Show information about the operating system", 8 | "script": "job_sysinfo_output.sh", 9 | "output": "html", 10 | "fields": [] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /examples/tutorial/tutorial_upload.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tutorial step 4: Uploads", 3 | "forms": [ 4 | { 5 | "name": "hello_world", 6 | "title": "Hello, world!", 7 | "description": "Greetings", 8 | "script": "job_upload.sh", 9 | "fields": [ 10 | { 11 | "name": "name", 12 | "title": "Name", 13 | "type": "string" 14 | }, 15 | { 16 | "name": "upload", 17 | "title": "Upload a file", 18 | "type": "file" 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /examples/tutorial/tutorial_validate.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tutorial step 4: Uploads", 3 | "forms": [ 4 | { 5 | "name": "hello_world", 6 | "title": "Hello, world!", 7 | "description": "Greetings", 8 | "script": "job_upload.sh", 9 | "fields": [ 10 | { 11 | "name": "name", 12 | "title": "Name", 13 | "type": "string", 14 | "minlen": 2, 15 | "maxlen": 10 16 | }, 17 | { 18 | "name": "upload", 19 | "title": "Upload a file", 20 | "type": "file", 21 | "required": true, 22 | "extensions": ["txt"] 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /examples/validate/README.md: -------------------------------------------------------------------------------- 1 | ScriptForm validate example 2 | =========================== 3 | 4 | This example shows extra validation options for fields. 5 | -------------------------------------------------------------------------------- /examples/validate/job_validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "string = $string" 4 | echo "integer = $integer" 5 | echo "float = $float" 6 | echo "date = $date" 7 | echo "radio = $radio" 8 | echo "text = $text" 9 | echo "password = $password" 10 | echo "file = $file" 11 | echo "file name = $file__name" 12 | -------------------------------------------------------------------------------- /examples/validate/validate.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Validation example", 3 | "forms": [ 4 | { 5 | "name": "validate", 6 | "title": "Validated form", 7 | "description": "This form is heavily validated", 8 | "submit_title": "Validate it", 9 | "script": "job_validate.sh", 10 | "fields": [ 11 | { 12 | "name": "string", 13 | "title": "A string of at least 5 and at most 7 characters.", 14 | "type": "string", 15 | "required": true, 16 | "minlen": 5, 17 | "maxlen": 7 18 | }, 19 | { 20 | "name": "string_nr", 21 | "title": "A non-required string of at least 5 and at most 7 characters.", 22 | "type": "string", 23 | "minlen": 5, 24 | "maxlen": 7 25 | }, 26 | { 27 | "name": "integer", 28 | "title": "An integer (min 10, max 20)", 29 | "type": "integer", 30 | "required": true, 31 | "min": 10, 32 | "max": 20 33 | }, 34 | { 35 | "name": "float", 36 | "title": "A real number (min 0.5, max 1.0)", 37 | "type": "float", 38 | "required": true, 39 | "min": 0.5, 40 | "max": 1.0 41 | }, 42 | { 43 | "name": "date", 44 | "title": "A date (in the month 2015-01)", 45 | "type": "date", 46 | "required": true, 47 | "min": "2015-01-01", 48 | "max": "2015-02-01" 49 | }, 50 | { 51 | "name": "radio", 52 | "title": "A radio", 53 | "type": "radio", 54 | "required": true, 55 | "options": [ 56 | ["One", "one"], 57 | ["Two", "two"], 58 | ["Three", "three"] 59 | ] 60 | }, 61 | { 62 | "name": "text", 63 | "title": "A text input field (min 10 chars, max 100 chars)", 64 | "type": "text", 65 | "required": true, 66 | "rows": 2, 67 | "cols": 50, 68 | "maxlen": 100, 69 | "minlen": 10 70 | }, 71 | { 72 | "name": "password", 73 | "title": "A password input field of at least 5 chars", 74 | "type": "password", 75 | "required": true, 76 | "minlen": 5 77 | }, 78 | { 79 | "name": "file", 80 | "title": "A file upload field", 81 | "type": "file", 82 | "required": true, 83 | "extensions": ["csv"] 84 | } 85 | ] 86 | } 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | requests 2 | flake8 3 | pylint 4 | markdown 5 | coverage 6 | pyinstaller 7 | -------------------------------------------------------------------------------- /src/daemon.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provide daemon capabilities via the Daemon class. 3 | """ 4 | 5 | import logging 6 | import os 7 | import sys 8 | import signal 9 | import time 10 | import errno 11 | import atexit 12 | 13 | 14 | class DaemonError(Exception): 15 | """ 16 | Default error for Daemon class. 17 | """ 18 | 19 | 20 | class Daemon(object): # pragma: no cover 21 | """ 22 | Daemonize the current process (detach it from the console). 23 | """ 24 | def __init__(self, pid_file, log_file=None, log_level=logging.INFO, 25 | foreground=False): 26 | if pid_file is None: 27 | self.pid_file = '{0}.pid'.format(os.path.basename(sys.argv[0])) 28 | else: 29 | self.pid_file = pid_file 30 | if log_file is None: 31 | self.log_file = '{0}.log'.format(os.path.basename(sys.argv[0])) 32 | else: 33 | self.log_file = log_file 34 | self.foreground = foreground 35 | 36 | log_fmt = '%(asctime)s:%(name)s:%(levelname)s:%(message)s' 37 | logging.basicConfig(level=log_level, 38 | format=log_fmt, 39 | filename=self.log_file, 40 | filemode='a') 41 | self.log = logging.getLogger('DAEMON') 42 | self.shutdown_callback = None 43 | 44 | def register_shutdown_callback(self, callback): 45 | """ 46 | Register a callback to be executed when the daemon is stopped. 47 | """ 48 | self.shutdown_callback = callback 49 | 50 | def start(self): 51 | """ 52 | Start the daemon. Raises a DaemonError if it's already running. 53 | """ 54 | self.log.info("Starting") 55 | if self.is_running(): 56 | self.log.error('Already running') 57 | raise DaemonError("Already running") 58 | if not self.foreground: 59 | self._fork() 60 | 61 | def stop(self): 62 | """ 63 | Stop the daemon. Raises a DaemonError if the daemon is ot running, 64 | which is determined by examaning the PID file. 65 | """ 66 | if not self.is_running(): 67 | raise DaemonError("Not running") 68 | 69 | pid = self.get_pid() 70 | 71 | # Kill the daemon and wait until the process is gone 72 | os.kill(pid, signal.SIGTERM) 73 | for _ in range(25): # 5 seconds 74 | time.sleep(0.2) 75 | if not self._pid_running(pid): 76 | break 77 | else: 78 | self.log.error("Couldn't stop the daemon.") 79 | 80 | def is_running(self): 81 | """ 82 | Check if the daemon is already running by looking at the PID file 83 | """ 84 | if self.get_pid() is None: 85 | return False 86 | else: 87 | return True 88 | 89 | def get_pid(self): 90 | """ 91 | Returns the PID of this daemon. If the daemon is not running (the PID 92 | file does not exist or the PID in the PID file does not exist), returns 93 | None. 94 | """ 95 | if not os.path.exists(self.pid_file): 96 | return None 97 | 98 | try: 99 | with open(self.pid_file, "r") as fh: 100 | pid = int(fh.read().strip()) 101 | except ValueError: 102 | return None 103 | 104 | if os.path.isdir('/proc/{0}/'.format(pid)): 105 | return pid 106 | else: 107 | os.unlink(self.pid_file) 108 | return None 109 | 110 | def _pid_running(self, pid): 111 | """ 112 | Returns True if the PID is running, False otherwise 113 | """ 114 | try: 115 | os.kill(pid, 0) 116 | except OSError as err: 117 | if err.errno == errno.ESRCH: 118 | return False 119 | return True 120 | 121 | def _fork(self): 122 | """ 123 | Fork the current process daemon-style. Forks twice, closes file 124 | descriptors, etc. A signal handler is also registered to be called if 125 | the daemon received a SIGTERM signal. 126 | """ 127 | # Fork a child and end the parent (detach from parent) 128 | pid = os.fork() 129 | if pid > 0: 130 | sys.exit(0) # End parent 131 | 132 | # Change some defaults so the daemon doesn't tie up dirs, etc. 133 | os.setsid() 134 | os.umask(0) 135 | 136 | # Fork a child and end parent (so init now owns process) 137 | pid = os.fork() 138 | if pid > 0: 139 | self.log.info("PID = %s", pid) 140 | with open(self.pid_file, "w") as fh: 141 | fh.write(str(pid)) 142 | sys.exit(0) # End parent 143 | 144 | atexit.register(self._cleanup) 145 | signal.signal(signal.SIGTERM, self._cleanup) 146 | 147 | # Close STDIN, STDOUT and STDERR so we don't tie up the controlling 148 | # terminal 149 | for fdescriptor in (0, 1, 2): 150 | try: 151 | os.close(fdescriptor) 152 | except OSError: 153 | pass 154 | 155 | # Reopen the closed file descriptors so other os.open() calls don't 156 | # accidentally get tied to the stdin etc. 157 | os.open("/dev/null", os.O_RDWR) # standard input (0) 158 | os.dup2(0, 1) # standard output (1) 159 | os.dup2(0, 2) # standard error (2) 160 | 161 | return pid 162 | 163 | def _cleanup(self, sig=None): 164 | """ 165 | Remove pid files and call registered shutodnw callbacks. 166 | """ 167 | self.log.info("Received signal %s", sig) 168 | if os.path.exists(self.pid_file): 169 | os.unlink(self.pid_file) 170 | self.shutdown_callback() 171 | -------------------------------------------------------------------------------- /src/formconfig.py: -------------------------------------------------------------------------------- 1 | """ 2 | FormConfig is the in-memory representation of a form configuration JSON file. 3 | It holds information (title, users, the form definitions) on the form 4 | configuration being served by this instance of ScriptForm. 5 | """ 6 | 7 | import logging 8 | import stat 9 | import os 10 | 11 | 12 | class FormConfigError(Exception): 13 | """ 14 | Default error for FormConfig errors 15 | """ 16 | 17 | 18 | class FormConfig(object): 19 | """ 20 | FormConfig is the in-memory representation of a form configuration JSON 21 | file. It holds information (title, users, the form definitions) on the 22 | form configuration being served by this instance of ScriptForm. 23 | """ 24 | def __init__(self, title, forms, users=None, static_dir=None, 25 | custom_css=None): 26 | self.title = title 27 | self.users = {} 28 | if users is not None: 29 | self.users = users 30 | self.forms = forms 31 | self.static_dir = static_dir 32 | self.custom_css = custom_css 33 | self.log = logging.getLogger('FORMCONFIG') 34 | 35 | # Validate scripts 36 | for form_def in self.forms: 37 | if not stat.S_IXUSR & os.stat(form_def.script)[stat.ST_MODE]: 38 | msg = "{0} is not executable".format(form_def.script) 39 | raise FormConfigError(msg) 40 | 41 | def get_form_def(self, form_name): 42 | """ 43 | Return the form definition for the form with name `form_name`. Returns 44 | an instance of FormDefinition class or raises ValueError if the form 45 | was not found. 46 | """ 47 | for form_def in self.forms: 48 | if form_def.name == form_name: 49 | return form_def 50 | 51 | raise ValueError("No such form: {0}".format(form_name)) 52 | 53 | def get_visible_forms(self, username=None): 54 | """ 55 | Return a list of all visible forms. Excluded forms are those that have 56 | the 'hidden' property set, and where the user has no access to. 57 | """ 58 | form_list = [] 59 | for form_def in self.forms: 60 | if form_def.allowed_users is not None and \ 61 | username not in form_def.allowed_users: 62 | continue # User is not allowed to run this form 63 | if form_def.hidden: 64 | continue # Don't show hidden forms in the list. 65 | else: 66 | form_list.append(form_def) 67 | return form_list 68 | -------------------------------------------------------------------------------- /src/formdefinition.py: -------------------------------------------------------------------------------- 1 | """ 2 | FormDefinition holds information about a single form and provides methods for 3 | validation of the form values. 4 | """ 5 | 6 | import os 7 | import datetime 8 | 9 | import runscript 10 | 11 | 12 | class ValidationError(Exception): 13 | """ 14 | Default exception for Validation errors 15 | """ 16 | 17 | 18 | class FormDefinition(object): 19 | """ 20 | FormDefinition holds information about a single form and provides methods 21 | for validation of the form values. 22 | """ 23 | def __init__(self, name, title, description, fields, script, 24 | fields_from=None, default_value=None, output='escaped', 25 | hidden=False, submit_title="Submit", allowed_users=None, 26 | run_as=None): 27 | self.name = name 28 | self.title = title 29 | self.description = description 30 | self.fields = fields 31 | self.script = script 32 | self.fields_from = fields_from 33 | self.default_value = default_value 34 | self.output = output 35 | self.hidden = hidden 36 | self.submit_title = submit_title 37 | self.allowed_users = allowed_users 38 | self.run_as = run_as 39 | 40 | self.validate_field_defs(self.get_fields()) 41 | 42 | def get_fields(self): 43 | """ 44 | Return the fields for the form either from statically defined fields in 45 | the form definition, or dynamically from an externally executable 46 | script. 47 | """ 48 | if self.fields is not None: 49 | return self.fields 50 | elif self.fields_from is not None: 51 | return runscript.from_file(self.fields_from) 52 | else: 53 | msg = "Missing either 'fields' or 'fields_from' in '{}' form" 54 | raise ValueError(msg.format(self.name)) 55 | 56 | def validate_field_defs(self, fields): 57 | """ 58 | Make sure all required properties are present when loading a field 59 | definition. 60 | """ 61 | required = ['name', 'title', 'type'] 62 | for field in fields: 63 | for prop_name in required: 64 | if prop_name not in field: 65 | raise KeyError("Missing required property '{0}' for field " 66 | "'{1}'".format(prop_name, str(field))) 67 | 68 | def get_field_def(self, field_name): 69 | """ 70 | Return the field definition for `field_name`. 71 | """ 72 | for field in self.get_fields(): 73 | if field['name'] == field_name: 74 | return field 75 | raise KeyError("Unknown field: {0}".format(field_name)) 76 | 77 | def validate(self, form_values): 78 | """ 79 | Validate all relevant fields for this form against form_values. This 80 | happens when the form is submitted. Returns a set with the errors and 81 | new values. 82 | """ 83 | errors = {} 84 | values = form_values.copy() 85 | 86 | # First make sure all required fields are there 87 | for field in self.get_fields(): 88 | field_required = ('required' in field and 89 | field['required'] is True) 90 | field_missing = (field['name'] not in form_values or 91 | form_values[field['name']] == '') 92 | if field_required and field_missing: 93 | errors.setdefault(field['name'], []).append( 94 | "This field is required" 95 | ) 96 | 97 | # Validate the field values, possible casting them to the correct type. 98 | for field in self.get_fields(): 99 | field_name = field['name'] 100 | if field_name in errors: 101 | # Skip fields that are required but missing, since they can't 102 | # be validated 103 | continue 104 | try: 105 | value = self._field_validate(field_name, form_values) 106 | if value is not None: 107 | values[field_name] = value 108 | except ValidationError as err: 109 | errors.setdefault(field_name, []).append(str(err)) 110 | 111 | return (errors, values) 112 | 113 | def _field_validate(self, field_name, form_values): 114 | """ 115 | Validate a field in this form. This does a dynamic call to a method on 116 | this class in the form 'validate_'. 117 | """ 118 | # Find field definition by iterating through all the fields. 119 | field_def = self.get_field_def(field_name) 120 | 121 | field_type = field_def['type'] 122 | validate_cb = getattr(self, 'validate_{0}'.format(field_type)) 123 | return validate_cb(field_def, form_values) 124 | 125 | def validate_string(self, field_def, form_values): 126 | """ 127 | Validate a form field of type 'string'. 128 | """ 129 | value = form_values[field_def['name']] 130 | maxlen = field_def.get('maxlen', None) 131 | minlen = field_def.get('minlen', None) 132 | 133 | if value == '' and field_def.get('required', False) is False: 134 | return '' 135 | if minlen is not None and len(value) < int(minlen): 136 | raise ValidationError("Minimum length is {0}".format(minlen)) 137 | if maxlen is not None and len(value) > int(maxlen): 138 | raise ValidationError("Maximum length is {0}".format(maxlen)) 139 | 140 | return value 141 | 142 | def validate_integer(self, field_def, form_values): 143 | """ 144 | Validate a form field of type 'integer'. 145 | """ 146 | value = form_values[field_def['name']] 147 | maxval = field_def.get('max', None) 148 | minval = field_def.get('min', None) 149 | 150 | if value == '' and field_def.get('required', False) is False: 151 | return '' 152 | try: 153 | value = int(value) 154 | except ValueError: 155 | raise ValidationError("Must be an integer number") from None 156 | if minval is not None and value < int(minval): 157 | raise ValidationError("Minimum value is {0}".format(minval)) 158 | if maxval is not None and value > int(maxval): 159 | raise ValidationError("Maximum value is {0}".format(maxval)) 160 | 161 | return int(value) 162 | 163 | def validate_float(self, field_def, form_values): 164 | """ 165 | Validate a form field of type 'float'. 166 | """ 167 | value = form_values[field_def['name']] 168 | maxval = field_def.get('max', None) 169 | minval = field_def.get('min', None) 170 | 171 | if value == '' and field_def.get('required', False) is False: 172 | return '' 173 | try: 174 | value = float(value) 175 | except ValueError: 176 | raise ValidationError("Must be an real (float) number") from None 177 | if minval is not None and value < float(minval): 178 | raise ValidationError("Minimum value is {0}".format(minval)) 179 | if maxval is not None and value > float(maxval): 180 | raise ValidationError("Maximum value is {0}".format(maxval)) 181 | 182 | return float(value) 183 | 184 | def validate_date(self, field_def, form_values): 185 | """ 186 | Validate a form field of type 'date'. 187 | """ 188 | value = form_values[field_def['name']] 189 | maxval = field_def.get('max', None) 190 | minval = field_def.get('min', None) 191 | 192 | if value == '' and field_def.get('required', False) is False: 193 | return '' 194 | try: 195 | value = datetime.datetime.strptime(value, '%Y-%m-%d').date() 196 | except ValueError: 197 | e_msg = "Invalid date, must be in form YYYY-MM-DD" 198 | raise ValidationError(e_msg) from None 199 | 200 | if minval is not None: 201 | if value < datetime.datetime.strptime(minval, '%Y-%m-%d').date(): 202 | raise ValidationError("Minimum value is {0}".format(minval)) 203 | if maxval is not None: 204 | if value > datetime.datetime.strptime(maxval, '%Y-%m-%d').date(): 205 | raise ValidationError("Maximum value is {0}".format(maxval)) 206 | 207 | return value 208 | 209 | def validate_radio(self, field_def, form_values): 210 | """ 211 | Validate a form field of type 'radio'. 212 | """ 213 | if 'options_from' in field_def: 214 | # Dynamic options from file 215 | active_options = runscript.from_file(field_def["options_from"]) 216 | else: 217 | # Static options defined in form definition 218 | active_options = field_def['options'] 219 | 220 | value = form_values[field_def['name']] 221 | if value not in [o[0] for o in active_options]: 222 | raise ValidationError( 223 | "Invalid value for radio button: {0}".format(value)) 224 | return value 225 | 226 | def validate_select(self, field_def, form_values): 227 | """ 228 | Validate a form field of type 'select'. 229 | """ 230 | if 'options_from' in field_def: 231 | # Dynamic options from file 232 | active_options = runscript.from_file(field_def["options_from"]) 233 | else: 234 | # Static options defined in form definition 235 | active_options = field_def['options'] 236 | 237 | value = form_values[field_def['name']] 238 | if value not in [o[0] for o in active_options]: 239 | raise ValidationError( 240 | "Invalid value for dropdown: {0}".format(value)) 241 | return value 242 | 243 | def validate_checkbox(self, field_def, form_values): 244 | """ 245 | Validate a form field of type 'checkbox'. 246 | """ 247 | value = form_values.get(field_def['name'], 'off') 248 | if value not in ['on', 'off']: 249 | raise ValidationError( 250 | "Invalid value for checkbox: {0}".format(value)) 251 | return value 252 | 253 | def validate_text(self, field_def, form_values): 254 | """ 255 | Validate a form field of type 'text'. 256 | """ 257 | value = form_values[field_def['name']] 258 | minlen = field_def.get('minlen', None) 259 | maxlen = field_def.get('maxlen', None) 260 | 261 | if value == '' and field_def.get('required', False) is False: 262 | return '' 263 | if minlen is not None and len(value) < int(minlen): 264 | raise ValidationError("Minimum length is {0}".format(minlen)) 265 | 266 | if maxlen is not None and len(value) > int(maxlen): 267 | raise ValidationError("Maximum length is {0}".format(maxlen)) 268 | 269 | return value 270 | 271 | def validate_password(self, field_def, form_values): 272 | """ 273 | Validate a form field of type 'password'. 274 | """ 275 | value = form_values[field_def['name']] 276 | minlen = field_def.get('minlen', None) 277 | 278 | if value == '' and field_def.get('required', False) is False: 279 | return '' 280 | if minlen is not None and len(value) < int(minlen): 281 | raise ValidationError("Minimum length is {0}".format(minlen)) 282 | 283 | return value 284 | 285 | def validate_file(self, field_def, form_values): 286 | """ 287 | Validate a form field of type 'file'. 288 | """ 289 | try: 290 | value = form_values[field_def['name']] 291 | except KeyError: 292 | # Field is missing. Check if it's required. 293 | if 'required' in field_def and field_def['required'] is True: 294 | raise ValidationError("Invalid file upload") from None 295 | else: 296 | return '' 297 | 298 | field_name = field_def['name'] 299 | upload_fname = form_values[u'{0}__name'.format(field_name)] 300 | upload_fname_ext = os.path.splitext(upload_fname)[-1].lstrip('.') 301 | extensions = field_def.get('extensions', None) 302 | 303 | if extensions is not None and upload_fname_ext not in extensions: 304 | msg = "Only file types allowed: {0}".format(u','.join(extensions)) 305 | raise ValidationError(msg) 306 | 307 | return value 308 | -------------------------------------------------------------------------------- /src/formrender.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | 3 | """ 4 | FormRender takes care of the rendering of forms to HTML. 5 | """ 6 | 7 | import datetime 8 | 9 | 10 | HTML_FIELD = u''' 11 |
  • 12 |

    {title}

    13 |

    14 | {h_input} 15 | {errors} 16 |

    17 |
  • 18 | ''' 19 | 20 | HTML_FIELD_CHECKBOX = u''' 21 |
  • 22 |

    23 | {h_input} 24 |

    {title}

    25 | {errors} 26 |

    27 |
  • 28 | ''' 29 | 30 | HTML_REQUIRED = u'{0} •' 32 | 33 | 34 | class FormRender(object): 35 | """ 36 | FormRender takes care of the rendering of forms to HTML. 37 | """ 38 | field_tpl = { 39 | "string": u'', 43 | "integer": u'', 46 | "float": u'', 49 | "date": u'', 52 | "file": u'', 54 | "password": u'', 57 | "text": u'', 60 | "radio_option": u'{label}
    ', 63 | "select_option": u'', 65 | "select": u'', 67 | "checkbox": u'', 69 | } 70 | 71 | def __init__(self, form_def): 72 | self.form_def = form_def 73 | 74 | def cast_params(self, params): 75 | """ 76 | Casts values in `params` dictionary to the correct types and values for 77 | use in the form rendering. 78 | """ 79 | new_params = params.copy() 80 | 81 | if 'required' in new_params: 82 | if new_params['required'] is False: 83 | new_params['required'] = "" 84 | else: 85 | new_params["required"] = "required" 86 | 87 | if 'classes' in new_params: 88 | new_params['classes'] = ' '.join(new_params['classes']) 89 | 90 | if 'checked' in new_params: 91 | if new_params['checked'] is False: 92 | new_params['checked'] = "" 93 | else: 94 | new_params['checked'] = "checked" 95 | 96 | return new_params 97 | 98 | def r_field(self, field_type, **kwargs): 99 | """ 100 | Render a generic field to HTML. 101 | """ 102 | params = self.cast_params(kwargs) 103 | method_name = 'r_field_{0}'.format(field_type) 104 | method = getattr(self, method_name) 105 | field = method(**params) # pylint: disable=not-callable 106 | 107 | if 'required' in kwargs and kwargs['required'] is True: 108 | return HTML_REQUIRED.format(field) 109 | else: 110 | return field 111 | 112 | def r_field_string(self, name, value, minlen=None, maxlen=None, size=50, 113 | required=False, classes='', style=""): 114 | """ 115 | Render a string field to HTML. 116 | """ 117 | tpl = self.field_tpl['string'] 118 | return tpl.format(name=name, value=value, minlen=minlen, maxlen=maxlen, 119 | size=size, required=required, classes=classes, 120 | style=style) 121 | 122 | def r_field_integer(self, name, value, minval=None, maxval=None, 123 | required=False, classes='', style=""): 124 | """ 125 | Render a integer field to HTML. 126 | """ 127 | tpl = self.field_tpl['integer'] 128 | return tpl.format(name=name, value=value, minval=minval, maxval=maxval, 129 | required=required, classes=classes, style=style) 130 | 131 | def r_field_float(self, name, value, minval=None, maxval=None, 132 | required=False, classes='', style=""): 133 | """ 134 | Render a float field to HTML. 135 | """ 136 | tpl = self.field_tpl['float'] 137 | return tpl.format(name=name, value=value, minval=minval, maxval=maxval, 138 | required=required, classes=classes, style=style) 139 | 140 | def r_field_date(self, name, value, minval='', maxval='', required=False, 141 | classes='', style=""): 142 | """ 143 | Render a date field to HTML. 144 | """ 145 | tpl = self.field_tpl['date'] 146 | if value == "today": 147 | value = datetime.datetime.now().strftime("%Y-%m-%d") 148 | if minval == "today": 149 | minval = datetime.datetime.now().strftime("%Y-%m-%d") 150 | if maxval == "today": 151 | maxval = datetime.datetime.now().strftime("%Y-%m-%d") 152 | return tpl.format(name=name, value=value, minval=minval, maxval=maxval, 153 | required=required, classes=classes, style=style) 154 | 155 | def r_field_file(self, name, required=False, classes='', style=""): 156 | """ 157 | Render a file field to HTML. 158 | """ 159 | tpl = self.field_tpl['file'] 160 | return tpl.format(name=name, required=required, classes=classes, 161 | style=style) 162 | 163 | def r_field_password(self, name, value, minlen=None, required=False, 164 | classes='', style=""): 165 | """ 166 | Render a password field to HTML. 167 | """ 168 | tpl = self.field_tpl['password'] 169 | return tpl.format(name=name, value=value, minlen=minlen, 170 | required=required, classes=classes, style=style) 171 | 172 | def r_field_text(self, name, value, rows=4, cols=80, minlen=None, 173 | maxlen=None, required=False, classes='', style=""): 174 | """ 175 | Render a text field to HTML. 176 | """ 177 | tpl = self.field_tpl['text'] 178 | return tpl.format(name=name, value=value, rows=rows, cols=cols, 179 | minlen=minlen, maxlen=maxlen, required=required, 180 | classes=classes, style=style) 181 | 182 | def r_field_radio(self, name, value, options, classes='', style=""): 183 | """ 184 | Render a radio field to HTML. 185 | """ 186 | tpl_option = self.field_tpl['radio_option'] 187 | radio_elems = [] 188 | for o_value, o_label in options: 189 | checked = '' 190 | if o_value == value: 191 | checked = 'checked' 192 | radio_elems.append(tpl_option.format(name=name, 193 | value=o_value, 194 | checked=checked, 195 | label=o_label, 196 | classes=classes, 197 | style=style)) 198 | return u''.join(radio_elems) 199 | 200 | def r_field_checkbox(self, name, checked, classes='', style=""): 201 | """ 202 | Render a checkbox field to HTML. 203 | """ 204 | tpl = self.field_tpl['checkbox'] 205 | return tpl.format(name=name, checked=checked, classes=classes, 206 | style=style) 207 | 208 | def r_field_select(self, name, value, options, classes='', style=""): 209 | """ 210 | Render a select field to HTML. 211 | """ 212 | tpl_option = self.field_tpl['select_option'] 213 | select_elems = [] 214 | for o_value, o_label in options: 215 | selected = '' 216 | if o_value == value: 217 | selected = 'selected' 218 | select_elems.append(tpl_option.format(value=o_value, 219 | selected=selected, 220 | label=o_label, 221 | style=style)) 222 | 223 | tpl = self.field_tpl['select'] 224 | return tpl.format(name=name, select_elems=''.join(select_elems), 225 | classes=classes, style=style) 226 | 227 | def r_form_line(self, field_type, title, h_input, classes, errors): 228 | """ 229 | Render a line (label + input) to HTML. 230 | """ 231 | if field_type == 'checkbox': 232 | html = HTML_FIELD_CHECKBOX 233 | else: 234 | html = HTML_FIELD 235 | 236 | return (html.format(classes=' '.join(classes), 237 | title=title, 238 | h_input=h_input, 239 | errors=u', '.join(errors))) 240 | -------------------------------------------------------------------------------- /src/runscript.py: -------------------------------------------------------------------------------- 1 | """ 2 | The runscript module is responsible for running external scripts and processing 3 | their output. 4 | """ 5 | 6 | import logging 7 | import os 8 | import pwd 9 | import grp 10 | import subprocess 11 | import json 12 | 13 | 14 | def from_file(fname): 15 | """ 16 | Read or execute `fname` and decode its contents as JSON. Used for reading 17 | parts of forms from external files or scripts. 18 | """ 19 | log = logging.getLogger(__name__) 20 | 21 | if not fname.startswith('/'): 22 | path = os.path.join(os.path.realpath(os.curdir), fname) 23 | else: 24 | path = fname 25 | 26 | if os.access(path, os.X_OK): 27 | # Executable. Run and grab output 28 | log.debug("Executing %s", path) 29 | proc = subprocess.Popen(path, 30 | shell=True, 31 | stdout=subprocess.PIPE, 32 | stderr=subprocess.PIPE, 33 | close_fds=True) 34 | stdout, stderr = proc.communicate(input) 35 | if proc.returncode != 0: 36 | log.error("%s returned non-zero exit code %s", 37 | path, 38 | proc.returncode) 39 | log.error(stderr) 40 | raise subprocess.CalledProcessError(proc.returncode, path, stderr) 41 | out = stdout 42 | else: 43 | # Normal file 44 | with open(path, 'r') as filehandle: 45 | out = filehandle.read() 46 | return json.loads(out) 47 | 48 | 49 | def run_as(uid, gid, groups): 50 | """ 51 | Closure that changes the current running user and groups. Called before 52 | executing scripts by Subprocess. 53 | """ 54 | def set_acc(): 55 | """ 56 | Change user and groups 57 | """ 58 | os.setgroups(groups) 59 | os.setgid(gid) 60 | os.setuid(uid) 61 | return set_acc 62 | 63 | 64 | def run_script(form_def, form_values, env, stdout=None, stderr=None): 65 | """ 66 | Perform a callback for the form `form_def`. This calls a script. 67 | `form_values` is a dictionary of validated values as returned by 68 | FormDefinition.validate(). If form_def.output is of type 'raw', `stdout` 69 | and `stderr` have to be open filehandles where the output of the 70 | callback should be written. The output of the script is hooked up to 71 | the output, depending on the output type. 72 | """ 73 | log = logging.getLogger('RUNSCRIPT') 74 | 75 | # Validate params 76 | if form_def.output == 'raw' and (stdout is None or stderr is None): 77 | msg = 'stdout and stderr cannot be none if script output ' \ 78 | 'is \'raw\'' 79 | raise ValueError(msg) 80 | 81 | # Pass form values to the script through the environment as strings. 82 | for key, value in form_values.items(): 83 | env[key] = str(value) 84 | 85 | # Get the user uid, gid and groups we should run as. If the current 86 | # user is root, we run as the given user or 'nobody' if no user was 87 | # specified. Otherwise, we run as the user we already are. 88 | if os.getuid() == 0: 89 | if form_def.run_as is not None: 90 | runas_pw = pwd.getpwnam(form_def.run_as) 91 | else: 92 | # Run as nobody 93 | runas_pw = pwd.getpwnam('nobody') 94 | runas_gr = grp.getgrgid(runas_pw.pw_gid) 95 | groups = [ 96 | g.gr_gid 97 | for g in grp.getgrall() 98 | if runas_pw.pw_name in g.gr_mem 99 | ] 100 | msg = "Running script as user={0}, gid={1}, groups={2}" 101 | run_as_fn = run_as(runas_pw.pw_uid, runas_pw.pw_gid, groups) 102 | log.info("%s", msg.format(runas_pw.pw_name, 103 | runas_gr.gr_name, 104 | str(groups))) 105 | else: 106 | run_as_fn = None 107 | if form_def.run_as is not None: 108 | log.critical("Not running as root, so we can't run the " 109 | "script as user '%s'", form_def.run_as) 110 | 111 | # If the form output type is 'raw', we directly stream the output to 112 | # the browser. Otherwise we store it for later displaying. 113 | if form_def.output == 'raw': 114 | try: 115 | proc = subprocess.Popen(form_def.script, 116 | shell=True, 117 | stdout=stdout, 118 | stderr=stderr, 119 | env=env, 120 | close_fds=True, 121 | preexec_fn=run_as_fn) 122 | stdout, stderr = proc.communicate(input) 123 | log.info("Exit code: %s", proc.returncode) 124 | return proc.returncode 125 | except OSError as err: 126 | log.exception(err) 127 | stderr.write(str(err) + '. Please see the log file.') 128 | return -1 129 | else: 130 | try: 131 | proc = subprocess.Popen(form_def.script, 132 | shell=True, 133 | stdin=subprocess.PIPE, 134 | stdout=subprocess.PIPE, 135 | stderr=subprocess.PIPE, 136 | env=env, 137 | close_fds=True, 138 | preexec_fn=run_as_fn) 139 | stdout, stderr = proc.communicate() 140 | log.info("Exit code: %s", proc.returncode) 141 | return { 142 | 'stdout': stdout, 143 | 'stderr': stderr, 144 | 'exitcode': proc.returncode 145 | } 146 | except OSError as err: 147 | log.exception(err) 148 | return { 149 | 'stdout': '', 150 | 'stderr': 'Internal error: {0}. Please see the log ' 151 | 'file.'.format(str(err)), 152 | 'exitcode': -1 153 | } 154 | -------------------------------------------------------------------------------- /src/scriptform.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Main ScriptForm program 6 | """ 7 | 8 | import sys 9 | import argparse 10 | import os 11 | import json 12 | import logging 13 | import threading 14 | import hashlib 15 | import getpass 16 | 17 | if hasattr(sys, 'dont_write_bytecode'): 18 | sys.dont_write_bytecode = True 19 | 20 | # pylint: disable=wrong-import-position 21 | from daemon import Daemon 22 | from formdefinition import FormDefinition 23 | from formconfig import FormConfig 24 | from webserver import ThreadedHTTPServer 25 | from webapp import ScriptFormWebApp 26 | 27 | 28 | class ScriptForm(object): 29 | """ 30 | 'Main' class that orchestrates parsing the Form configurations and running 31 | the webserver. 32 | """ 33 | def __init__(self, config_file, cache=True): 34 | self.config_file = config_file 35 | self.cache = cache 36 | self.log = logging.getLogger('SCRIPTFORM') 37 | self.form_config_singleton = None 38 | self.websrv = None 39 | self.running = False 40 | self.httpd = None 41 | 42 | # Init form config so it can raise errors about problems. 43 | self.get_form_config() 44 | 45 | def get_form_config(self): 46 | """ 47 | Read and return the form configuration in the form of a FormConfig 48 | instance. If it has already been read, a cached version is returned. 49 | """ 50 | # Cache 51 | if self.cache and self.form_config_singleton is not None: 52 | return self.form_config_singleton 53 | 54 | with open(self.config_file, "r") as fh: 55 | file_contents = fh.read() 56 | try: 57 | config = json.loads(file_contents) 58 | except ValueError as err: 59 | sys.stderr.write("Error in form configuration '{}': {}\n".format( 60 | self.config_file, err)) 61 | sys.exit(1) 62 | 63 | static_dir = None 64 | custom_css = None 65 | users = None 66 | forms = [] 67 | 68 | if 'static_dir' in config: 69 | static_dir = config['static_dir'] 70 | if 'custom_css' in config: 71 | with open(config["custom_css"], "r") as fh: 72 | custom_css = fh.read() 73 | if 'users' in config: 74 | users = config['users'] 75 | for form in config['forms']: 76 | form_name = form['name'] 77 | if not form['script'].startswith('/'): 78 | # Script is relative to the current dir 79 | script = os.path.join(os.path.realpath(os.curdir), 80 | form['script']) 81 | else: 82 | # Absolute path to the script 83 | script = form['script'] 84 | forms.append( 85 | FormDefinition(form_name, 86 | form['title'], 87 | form['description'], 88 | form.get('fields', None), 89 | script, 90 | fields_from=form.get("fields_from", None), 91 | default_value=form.get('default_value', ""), 92 | output=form.get('output', 'escaped'), 93 | hidden=form.get('hidden', False), 94 | submit_title=form.get('submit_title', 'Submit'), 95 | allowed_users=form.get('allowed_users', None), 96 | run_as=form.get('run_as', None)) 97 | ) 98 | 99 | form_config = FormConfig( 100 | config['title'], 101 | forms, 102 | users, 103 | static_dir, 104 | custom_css 105 | ) 106 | self.form_config_singleton = form_config 107 | return form_config 108 | 109 | def run(self, listen_addr='0.0.0.0', listen_port=8081): 110 | """ 111 | Start the webserver on address `listen_addr` and port `listen_port`. 112 | This call is blocking until the user hits Ctrl-c, the shutdown() method 113 | is called or something like SystemExit is raised in a handler. 114 | """ 115 | ScriptFormWebApp.scriptform = self 116 | self.httpd = ThreadedHTTPServer((listen_addr, listen_port), 117 | ScriptFormWebApp) 118 | self.httpd.daemon_threads = True 119 | self.log.info("Listening on %s:%s", listen_addr, listen_port) 120 | self.running = True 121 | try: 122 | self.httpd.serve_forever() 123 | except KeyboardInterrupt: 124 | pass 125 | self.running = False 126 | 127 | def shutdown(self): 128 | """ 129 | Shutdown the server. This interupts the run() method and must thus be 130 | run in a seperate thread. 131 | """ 132 | self.log.info("Attempting server shutdown") 133 | 134 | def t_shutdown(scriptform_instance): 135 | """ 136 | Callback for when the server is shutdown. 137 | """ 138 | scriptform_instance.log.info(self.websrv) 139 | # Undocumented feature to shutdow the server. 140 | scriptform_instance.httpd.socket.close() 141 | scriptform_instance.httpd.shutdown() 142 | 143 | # We need to spawn a new thread in which the server is shut down, 144 | # because doing it from the main thread blocks, since the server is 145 | # waiting for connections.. 146 | thread = threading.Thread(target=t_shutdown, args=(self,)) 147 | thread.start() 148 | 149 | 150 | def main(): # pragma: no cover 151 | """ 152 | main method 153 | """ 154 | desc = """A stand-alone webserver that automatically generates forms """ \ 155 | """from JSON to serve as frontends to scripts.""" 156 | parser = argparse.ArgumentParser(description=desc) 157 | parser.add_argument('--version', 158 | action='version', 159 | version='%(prog)s %%VERSION%%') 160 | parser.add_argument('-g', '--generate-pw', 161 | action='store_true', 162 | default=False, 163 | help='Generate password') 164 | parser.add_argument('-p', '--port', 165 | metavar='PORT', 166 | dest='port', 167 | type=int, 168 | default=8081, 169 | help='Port to listen on (default=8081)') 170 | parser.add_argument('-f', '--foreground', 171 | dest='foreground', 172 | action='store_true', 173 | default=False, 174 | help='Run in foreground (debugging)') 175 | parser.add_argument('-r', '--reload', 176 | dest='reload', 177 | action='store_true', 178 | default=False, 179 | help='Reload form config on every request (DEV)') 180 | parser.add_argument('--pid-file', 181 | metavar='PATH', 182 | dest='pid_file', 183 | type=str, 184 | default=None, 185 | help='Pid file') 186 | parser.add_argument('--log-file', 187 | metavar='PATH', 188 | dest='log_file', 189 | type=str, 190 | default=None, 191 | help='Log file') 192 | parser.add_argument('--stop', 193 | dest='action_stop', 194 | action='store_true', 195 | default=None, 196 | help='Stop daemon') 197 | parser.add_argument(dest='config', 198 | metavar="CONFIG_FILE", 199 | help="Path to form definition config", 200 | ) 201 | options = parser.parse_args() 202 | 203 | if options.generate_pw: 204 | # Generate a password for use in the `users` section 205 | plain_pw = getpass.getpass() 206 | if plain_pw != getpass.getpass('Repeat password: '): 207 | sys.stderr.write("Passwords do not match.\n") 208 | sys.exit(1) 209 | sha = hashlib.sha256(plain_pw.encode('utf8')).hexdigest() 210 | sys.stdout.write("{}\n".format(sha)) 211 | sys.exit(0) 212 | else: 213 | # Switch to dir of form definition configuration 214 | formconfig_path = os.path.realpath(options.config) 215 | os.chdir(os.path.dirname(formconfig_path)) 216 | 217 | # Initialize daemon so we can start or stop it 218 | daemon = Daemon(options.pid_file, options.log_file, 219 | foreground=options.foreground) 220 | 221 | if options.action_stop: 222 | daemon.stop() 223 | sys.exit(0) 224 | else: 225 | cache = not options.reload 226 | scriptform_instance = ScriptForm(formconfig_path, cache=cache) 227 | daemon.register_shutdown_callback(scriptform_instance.shutdown) 228 | daemon.start() 229 | scriptform_instance.run(listen_port=options.port) 230 | 231 | 232 | if __name__ == "__main__": # pragma: no cover 233 | main() 234 | -------------------------------------------------------------------------------- /src/webapp.py: -------------------------------------------------------------------------------- 1 | """ 2 | The webapp part of Scriptform, which takes care of serving requests and 3 | handling them. 4 | """ 5 | 6 | import html 7 | import logging 8 | import tempfile 9 | import os 10 | import base64 11 | import hashlib 12 | import copy 13 | 14 | from formrender import FormRender 15 | from webserver import HTTPError, RequestHandler 16 | import runscript 17 | 18 | 19 | HTML_HEADER = u''' 20 | 21 | 22 | 85 | 86 | 87 |

    {title}

    88 |
    89 | ''' 90 | 91 | HTML_FOOTER = u''' 92 |
    93 | Powered by 94 | Scriptform 95 | v%%VERSION%% 96 |
    97 |
    98 | 99 | 100 | ''' 101 | 102 | HTML_LIST = u''' 103 | {header} 104 |
    105 | {form_list} 106 |
    107 | {footer} 108 | ''' 109 | 110 | HTML_FORM = u''' 111 | {header} 112 |
    113 |

    {title}

    114 |

    {description}

    115 |
    117 | 118 | 129 |
    130 |
    131 | {footer} 132 | ''' 133 | 134 | HTML_FORM_LIST = u''' 135 |
  • 136 |

    {title}

    137 |

    {description}

    138 | 139 | {title} 140 | 141 |
  • 142 | ''' 143 | 144 | HTML_SUBMIT_RESPONSE = u''' 145 | {header} 146 |
    147 |

    {title}

    148 |

    Result

    149 |
    {msg}
    150 | 158 |
    159 | {footer} 160 | ''' 161 | 162 | 163 | def censor_form_values(form_def, form_values): 164 | """ 165 | Remove sensitive field values from form_values dict. 166 | """ 167 | censored_form_values = copy.copy(form_values) 168 | for field in form_def.get_fields(): 169 | if field['type'] == 'password': 170 | censored_form_values[field['name']] = '********' 171 | return censored_form_values 172 | 173 | 174 | class ScriptFormWebApp(RequestHandler): 175 | """ 176 | This class is a request handler for the webserver. 177 | """ 178 | def index(self): 179 | """ 180 | Index handler. If there's only one form defined, render that form. 181 | Otherwise render a list of available forms. 182 | """ 183 | form_config = self.scriptform.get_form_config() 184 | 185 | username = self.auth() 186 | visible_forms = form_config.get_visible_forms(username) 187 | if len(visible_forms) == 1: 188 | first_form = visible_forms[0] 189 | return self.h_form(first_form.name) 190 | else: 191 | return self.h_list() 192 | 193 | def auth(self): 194 | """ 195 | Verify that the user is authenticated. This is required if the form 196 | definition contains a 'users' field (unless pre-auth from a front-end 197 | such as Apache is used). Returns the username if the user is validated 198 | or None if no validation is required. Otherwise, raises a 401 HTTP 199 | back to the client. 200 | """ 201 | form_config = self.scriptform.get_form_config() 202 | username = None 203 | 204 | # Allow pre-auth from e.g. Apache htauth 205 | if 'REMOTE_USER' in self.headers: 206 | username = self.headers.get('REMOTE_USER') 207 | return self.headers.get('REMOTE_USER') 208 | 209 | # If a 'users' element was present in the form configuration file, the 210 | # user must be authenticated. 211 | if form_config.users: 212 | auth_header = self.headers.get("Authorization") 213 | if auth_header is not None: 214 | # Validate the username and password 215 | auth_unpw = auth_header.split(' ', 1)[1].encode('utf-8') 216 | username, password = \ 217 | base64.b64decode(auth_unpw).decode('utf-8').split(":", 1) 218 | pw_hash = hashlib.sha256(password.encode('utf-8')).hexdigest() 219 | if username in form_config.users and \ 220 | pw_hash == form_config.users[username]: 221 | # Valid username and password. Return the username. 222 | return username 223 | 224 | # Authentication needed, but not provided or wrong username/pw. 225 | headers = {"WWW-Authenticate": 'Basic realm="Private Area"'} 226 | raise HTTPError(401, 'Authenticate', headers) 227 | 228 | # No authentication required. Return None as the username. 229 | return None 230 | 231 | def h_list(self): 232 | """ 233 | Render a list of available forms. 234 | """ 235 | username = self.auth() 236 | form_config = self.scriptform.get_form_config() 237 | h_form_list = [] 238 | for form_def in form_config.get_visible_forms(username): 239 | h_form_list.append( 240 | HTML_FORM_LIST.format( 241 | title=form_def.title, 242 | description=form_def.description, 243 | name=form_def.name 244 | ) 245 | ) 246 | 247 | output = HTML_LIST.format( 248 | header=HTML_HEADER.format(title=form_config.title, 249 | custom_css=form_config.custom_css), 250 | footer=HTML_FOOTER, 251 | form_list=u''.join(h_form_list) 252 | ) 253 | self.send_response(200) 254 | self.send_header('Content-type', 'text/html') 255 | self.end_headers() 256 | self.wfile.write(output.encode('utf8')) 257 | 258 | def h_form(self, form_name, errors=None, **form_values): 259 | """ 260 | Render a form. 261 | """ 262 | def render_field(field, errors): 263 | """ 264 | Render a HTML field. 265 | """ 266 | params = { 267 | 'name': field['name'], 268 | 'classes': [], 269 | } 270 | 271 | if field.get('hidden', None): 272 | params['classes'].append('hidden') 273 | 274 | if field.get('required', None): 275 | params['classes'].append('required') 276 | 277 | params['classes'].extend(field.get('classes', '').split()) 278 | 279 | params["style"] = field.get("style", "") 280 | 281 | # Get field-specific parameters 282 | if field['type'] not in ('file', 'checkbox'): 283 | default_value = field.get('default_value', '') 284 | params['value'] = form_values.get(field['name'], default_value) 285 | 286 | if field['type'] not in ('radio', 'checkbox', 'select'): 287 | params['required'] = field.get('required', False) 288 | 289 | if field['type'] == 'string': 290 | params['size'] = field.get('size', '') 291 | 292 | if field['type'] in ('string', 'password', 'text'): 293 | params['minlen'] = field.get('minlen', '') 294 | 295 | if field['type'] in ('string', 'text'): 296 | params['maxlen'] = field.get('maxlen', '') 297 | 298 | if field['type'] in ('integer', 'float'): 299 | params['minval'] = field.get('min', '') 300 | params['maxval'] = field.get('max', '') 301 | 302 | if field['type'] == 'date': 303 | params['minval'] = field.get('min', '') 304 | params['maxval'] = field.get('max', '') 305 | 306 | if field['type'] == 'text': 307 | params['rows'] = field.get('rows', '') 308 | params['cols'] = field.get('cols', '') 309 | 310 | if field['type'] == 'radio': 311 | if 'options_from' in field: 312 | fname = field['options_from'] 313 | options = runscript.from_file(fname) 314 | else: 315 | options = field['options'] 316 | 317 | if not form_values.get(field['name'], None): 318 | # Set default value 319 | params['value'] = options[0][0] 320 | 321 | if field['type'] in ('radio', 'select'): 322 | if 'options_from' in field: 323 | fname = field['options_from'] 324 | params['options'] = runscript.from_file(fname) 325 | else: 326 | params['options'] = field['options'] 327 | 328 | if field['type'] == 'checkbox': 329 | # Set default value from field definition 330 | params['checked'] = False 331 | if 'checked' in field and field['checked']: 332 | params['checked'] = True 333 | 334 | # Set value from submitted form if applicable 335 | if field['name'] in form_values: 336 | if form_values[field['name']] == 'on': 337 | params['checked'] = True 338 | else: 339 | params['checked'] = False 340 | 341 | h_input = fr_inst.r_field(field['type'], **params) 342 | 343 | return fr_inst.r_form_line(field['type'], field['title'], 344 | h_input, params['classes'], errors) 345 | 346 | if errors is None: 347 | errors = {} 348 | 349 | username = self.auth() 350 | form_config = self.scriptform.get_form_config() 351 | fr_inst = FormRender(None) 352 | 353 | # Make sure the user is allowed to access this form. 354 | form_def = form_config.get_form_def(form_name) 355 | if form_def.allowed_users is not None and \ 356 | username not in form_def.allowed_users: 357 | raise HTTPError(403, "You're not authorized to view this form") 358 | 359 | html_errors = u'' 360 | if errors: 361 | html_errors = u'
      ' 362 | for error in errors: 363 | html_errors += u'
    • {0}
    • '.format(error) 364 | html_errors += u'
    ' 365 | 366 | output = HTML_FORM.format( 367 | header=HTML_HEADER.format(title=form_config.title, 368 | custom_css=form_config.custom_css), 369 | footer=HTML_FOOTER, 370 | title=form_def.title, 371 | description=form_def.description, 372 | errors=html_errors, 373 | name=form_def.name, 374 | fields=u''.join( 375 | [render_field(f, errors.get(f['name'], [])) 376 | for f in form_def.get_fields()] 377 | ), 378 | submit_title=form_def.submit_title 379 | ) 380 | self.send_response(200) 381 | self.send_header('Content-type', 'text/html') 382 | self.end_headers() 383 | self.wfile.write(output.encode('utf8')) 384 | 385 | def h_submit(self, form_values): 386 | """ 387 | Handle the submitting of a form by validating the values and then doing 388 | a callback to a script. How the output is handled depends on settings 389 | in the form definition. 390 | """ 391 | username = self.auth() 392 | 393 | form_config = self.scriptform.get_form_config() 394 | form_name = form_values.getfirst('form_name', None) 395 | form_def = form_config.get_form_def(form_name) 396 | if form_def.allowed_users is not None and \ 397 | username not in form_def.allowed_users: 398 | raise HTTPError(403, "You're not authorized to view this form") 399 | 400 | # Convert FieldStorage to a simple dict, because we're not allowd to 401 | # add items to it. For normal fields, the form field name becomes the 402 | # key and the value becomes the field value. For file upload fields, we 403 | # stream the uploaded file to a temp file and then put the temp file in 404 | # the destination dict. We also add an extra field with the originally 405 | # uploaded file's name. 406 | values = {} 407 | tmp_files = [] 408 | for field_name in form_values: 409 | field = form_values[field_name] 410 | if field.filename is not None: 411 | # Field is an uploaded file. Stream it to a temp file if 412 | # something was actually uploaded 413 | if field.filename == '': 414 | continue 415 | tmp_fname = tempfile.mktemp(prefix="scriptform_") 416 | with open(tmp_fname, "wb") as tmp_file: 417 | while True: 418 | buf = field.file.read(1024 * 16) 419 | if not buf: 420 | break 421 | tmp_file.write(buf) 422 | field.file.close() 423 | 424 | tmp_files.append(tmp_fname) # For later cleanup 425 | values[field_name] = tmp_fname 426 | values['{0}__name'.format(field_name)] = field.filename 427 | else: 428 | # Field is a normal form field. Store its value. 429 | values[field_name] = form_values.getfirst(field_name, None) 430 | 431 | form_errors, form_values = form_def.validate(values) 432 | 433 | if not form_errors: 434 | # Call script. If a result is returned, we wrap its output in some 435 | # nice HTML. If no result is returned, the output was raw and the 436 | # callback should have written its own response to the self.wfile 437 | # filehandle. 438 | 439 | # Log the callback and its parameters for auditing purposes. 440 | log = logging.getLogger('CALLBACK_AUDIT') 441 | cwd = os.path.realpath(os.curdir) 442 | log.info("Calling script: %s", form_def.script) 443 | log.info("Current working dir: %s", cwd) 444 | log.info("User: %s", username) 445 | log.info("Vars: %s", censor_form_values(form_def, form_values)) 446 | 447 | form_def = form_config.get_form_def(form_name) 448 | 449 | # Construct base environment. The field values are added in 450 | # run_scripts. 451 | env = os.environ.copy() 452 | env["__SF__FORM"] = form_name 453 | if username is not None: 454 | env["__SF__USER"] = username 455 | 456 | result = runscript.run_script(form_def, form_values, env, 457 | self.wfile, self.wfile) 458 | if form_def.output != 'raw': 459 | # Ignore everything if we're doing raw output, since it's the 460 | # scripts responsibility. 461 | if result['exitcode'] != 0: 462 | stderr = html.escape(result['stderr'].decode('utf8')) 463 | msg = u'{0}'.format(stderr) 464 | else: 465 | if form_def.output == 'escaped': 466 | stdout = html.escape(result['stdout'].decode('utf8')) 467 | msg = u'
    {0}
    '.format(stdout) 468 | else: 469 | # Non-escaped output (html, usually) 470 | msg = result['stdout'].decode('utf8') 471 | 472 | output = HTML_SUBMIT_RESPONSE.format( 473 | header=HTML_HEADER.format( 474 | title=form_config.title, 475 | custom_css=form_config.custom_css 476 | ), 477 | footer=HTML_FOOTER, 478 | title=form_def.title, 479 | form_name=form_def.name, 480 | msg=msg, 481 | ) 482 | self.send_response(200) 483 | self.send_header('Content-type', 'text/html') 484 | self.end_headers() 485 | self.wfile.write(output.encode('utf8')) 486 | else: 487 | # Form had errors 488 | form_values.pop('form_name') 489 | self.h_form(form_name, form_errors, **form_values) 490 | 491 | # Clean up uploaded files 492 | for file_name in tmp_files: 493 | if os.path.exists(file_name): 494 | os.unlink(file_name) 495 | 496 | def h_static(self, fname): 497 | """Serve static files""" 498 | form_config = self.scriptform.get_form_config() 499 | 500 | if not form_config.static_dir: 501 | raise HTTPError(501, "Static file serving not enabled") 502 | 503 | if '..' in fname: 504 | raise HTTPError(403, "Invalid file name") 505 | 506 | path = os.path.join(form_config.static_dir, fname) 507 | if not os.path.exists(path): 508 | raise HTTPError(404, "Not found") 509 | 510 | self.send_response(200) 511 | self.end_headers() 512 | with open(path, "rb") as static_file: 513 | self.wfile.write(static_file.read()) 514 | -------------------------------------------------------------------------------- /src/webserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic web server / framework. 3 | """ 4 | 5 | from socketserver import ThreadingMixIn 6 | from http.server import HTTPServer, BaseHTTPRequestHandler 7 | import urllib.parse 8 | import cgi 9 | 10 | 11 | class HTTPError(Exception): 12 | """ 13 | HTTPError may be thrown by routes to indicate HTTP errors such as 404, 301, 14 | etc. They are caught by the 'framework' and sent to the client's browser. 15 | """ 16 | def __init__(self, status_code, msg, headers=None): 17 | assert isinstance(status_code, int) 18 | assert isinstance(msg, str) 19 | 20 | if headers is None: 21 | headers = {} 22 | self.status_code = status_code 23 | self.msg = msg 24 | self.headers = headers 25 | Exception.__init__(self, status_code, msg, headers) 26 | 27 | 28 | class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 29 | """ 30 | Base class for multithreaded HTTP servers. 31 | """ 32 | 33 | 34 | class RequestHandler(BaseHTTPRequestHandler): 35 | """ 36 | Basic web server request handler. Handles GET and POST requests. You should 37 | inherit from this class and implement h_ methods for handling requests. 38 | If no path is set, it dispatches to the 'index' or 'default' method. 39 | """ 40 | def log_message(self, fmt, *args): # pylint: disable=arguments-differ 41 | """Overrides BaseHTTPRequestHandler which logs to the console. We log 42 | to our log file instead""" 43 | fmt = "{0} {1}" 44 | self.scriptform.log.info(fmt.format(self.address_string(), args)) 45 | 46 | def do_GET(self): # pylint: disable=invalid-name 47 | """ 48 | Handle a GET request. 49 | """ 50 | self._call(*self._parse(self.path.lstrip('/'))) 51 | 52 | def do_POST(self): # pylint: disable=invalid-name 53 | """ 54 | Handle a POST request. 55 | """ 56 | form_values = cgi.FieldStorage( 57 | fp=self.rfile, 58 | headers=self.headers, 59 | environ={'REQUEST_METHOD': 'POST'}) 60 | self._call(self.path.strip('/'), params={'form_values': form_values}) 61 | 62 | def _parse(self, reqinfo): 63 | """ 64 | Parse information from a request. 65 | """ 66 | url_comp = urllib.parse.urlsplit(reqinfo) 67 | path = url_comp.path 68 | query_vars = urllib.parse.parse_qs(url_comp.query) 69 | # Only return the first value of each query var. E.g. for 70 | # "?foo=1&foo=2" return '1'. 71 | var_values = dict([(k, v[0]) for k, v in query_vars.items()]) 72 | return (path.strip('/'), var_values) 73 | 74 | def _call(self, path, params): 75 | """ 76 | Find a method to call on self.app_class based on `path` and call it. 77 | The method that's called is in the form 'h_'. If no path was 78 | given, it will try to call the 'index' method. If no method could be 79 | found but a `default` method exists, it is called. Otherwise 404 is 80 | sent. 81 | 82 | Methods should take care of sending proper headers and content 83 | themselves using self.send_response(), self.send_header(), 84 | self.end_header() and by writing to self.wfile. 85 | """ 86 | method_name = 'h_{0}'.format(path) 87 | method_cb = None 88 | try: 89 | if hasattr(self, method_name) and \ 90 | callable(getattr(self, method_name)): 91 | method_cb = getattr(self, method_name) 92 | elif path == '' and hasattr(self, 'index'): 93 | method_cb = getattr(self, 'index') 94 | elif hasattr(self, 'default'): 95 | method_cb = getattr(self, 'default') 96 | else: 97 | raise HTTPError(404, "Not found") 98 | method_cb(**params) 99 | except HTTPError as err: 100 | # HTTP erors are generally thrown by the webapp on purpose. Send 101 | # error to the browser. 102 | if err.status_code not in (401, ): 103 | self.scriptform.log.exception(err) 104 | self.send_response(err.status_code) 105 | for header_k, header_v in err.headers.items(): 106 | self.send_header(header_k, header_v) 107 | self.end_headers() 108 | self.wfile.write("Error {0}: {1}".format(err.status_code, 109 | err.msg).encode('utf-8')) 110 | self.wfile.flush() 111 | return False 112 | except Exception as err: 113 | self.scriptform.log.exception(err) 114 | self.send_error(500, "Internal server error") 115 | raise 116 | -------------------------------------------------------------------------------- /test/static/ssh_server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/scriptform/8afe2b3f752413f6fd7eb268dff4f2edd08933c3/test/static/ssh_server.png -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import unittest 4 | import json 5 | import os 6 | import copy 7 | import threading 8 | import time 9 | import requests 10 | import re 11 | import random 12 | 13 | 14 | def gen_random_file(fname, size=1024): 15 | with open(fname, 'wb') as fh: 16 | for i in range(size): 17 | fh.write(chr(random.randint(0, 255)).encode('utf-8')) 18 | 19 | 20 | class FormConfigTestCase(unittest.TestCase): 21 | """ 22 | Test the proper low-level handling of form configurations such as loading, 23 | callbacks, etc. 24 | """ 25 | @classmethod 26 | def tearDownClass(cls): 27 | if os.path.exists('tmp_stdout'): 28 | os.unlink('tmp_stdout') 29 | if os.path.exists('tmp_stderr'): 30 | os.unlink('tmp_stderr') 31 | 32 | def testNoSuchForm(self): 33 | """Getting non-existing form should raise ValueError""" 34 | sf = scriptform.ScriptForm('test_formconfig_hidden.json') 35 | fc = sf.get_form_config() 36 | self.assertRaises(ValueError, fc.get_form_def, 'nonexisting') 37 | 38 | def testMissingScript(self): 39 | """Missing script callbacks should raise an OSError""" 40 | self.assertRaises(OSError, scriptform.ScriptForm, 'test_formconfig_missingscript.json') 41 | 42 | def testNoExec(self): 43 | """Non-executable script callbacks should raise an FormConfigError""" 44 | from formconfig import FormConfigError 45 | self.assertRaises(FormConfigError, scriptform.ScriptForm, 'test_formconfig_noexec.json') 46 | 47 | def testHidden(self): 48 | """Hidden forms should not show up in the list of forms""" 49 | sf = scriptform.ScriptForm('test_formconfig_hidden.json') 50 | fc = sf.get_form_config() 51 | self.assertTrue(fc.get_visible_forms() == []) 52 | 53 | def testCallbackStore(self): 54 | """Test a callback that returns output in strings""" 55 | sf = scriptform.ScriptForm('test_formconfig_callback.json') 56 | fc = sf.get_form_config() 57 | fd = fc.get_form_def('test_store') 58 | res = runscript.run_script(fd, {}, {}) 59 | self.assertEqual(res['exitcode'], 33) 60 | self.assertTrue(b'stdout' in res['stdout']) 61 | self.assertTrue(b'stderr' in res['stderr']) 62 | 63 | def testCallbackRaw(self): 64 | """Test a callback that returns raw output""" 65 | sf = scriptform.ScriptForm('test_formconfig_callback.json') 66 | fc = sf.get_form_config() 67 | fd = fc.get_form_def('test_raw') 68 | stdout = open('tmp_stdout', 'w+') # can't use StringIO 69 | stderr = open('tmp_stderr', 'w+') 70 | exitcode = runscript.run_script(fd, {}, {}, stdout, stderr) 71 | stdout.seek(0) 72 | stderr.seek(0) 73 | self.assertTrue(exitcode == 33) 74 | self.assertTrue('stdout' in stdout.read()) 75 | stdout.close() 76 | stderr.close() 77 | 78 | def testCallbackMissingParams(self): 79 | """ 80 | """ 81 | sf = scriptform.ScriptForm('test_formconfig_callback.json') 82 | fc = sf.get_form_config() 83 | fd = fc.get_form_def('test_raw') 84 | self.assertRaises(ValueError, runscript.run_script, fd, {}, {}) 85 | 86 | 87 | class FormDefinitionTest(unittest.TestCase): 88 | """ 89 | Form Definition tests. Mostly directly testing if validations work. 90 | """ 91 | def setUp(self): 92 | self.sf = scriptform.ScriptForm('test_formdefinition_validate.json') 93 | self.fc = self.sf.get_form_config() 94 | 95 | def testUnknownFieldError(self): 96 | fd = self.fc.get_form_def('test_required') 97 | self.assertRaises(KeyError, fd.get_field_def, 'nosuchfield') 98 | 99 | def testRequired(self): 100 | fd = self.fc.get_form_def('test_required') 101 | form_values = {} 102 | errors, values = fd.validate(form_values) 103 | self.assertIn('string', errors) 104 | self.assertIn('required', errors['string'][0]) 105 | 106 | def testValidateStringMin(self): 107 | fd = self.fc.get_form_def('test_val_string') 108 | form_values = {"val_string": "123"} 109 | errors, values = fd.validate(form_values) 110 | self.assertIn('val_string', errors) 111 | self.assertIn('Minimum', errors['val_string'][0]) 112 | 113 | def testValidateStringMax(self): 114 | fd = self.fc.get_form_def('test_val_string') 115 | form_values = {"val_string": "1234567"} 116 | errors, values = fd.validate(form_values) 117 | self.assertIn('val_string', errors) 118 | self.assertIn('Maximum', errors['val_string'][0]) 119 | 120 | def testValidateStringValue(self): 121 | fd = self.fc.get_form_def('test_val_string') 122 | form_values = {"val_string": "1234"} 123 | errors, values = fd.validate(form_values) 124 | self.assertNotIn('val_string', errors) 125 | self.assertEqual(values['val_string'], "1234") 126 | 127 | def testValidateIntegerInvalid(self): 128 | fd = self.fc.get_form_def('test_val_integer') 129 | form_values = {"val_integer": 'three'} 130 | errors, values = fd.validate(form_values) 131 | self.assertIn('val_integer', errors) 132 | self.assertIn('Must be a', errors['val_integer'][0]) 133 | 134 | def testValidateIntegerMin(self): 135 | fd = self.fc.get_form_def('test_val_integer') 136 | form_values = {"val_integer": 3} 137 | errors, values = fd.validate(form_values) 138 | self.assertIn('val_integer', errors) 139 | self.assertIn('Minimum', errors['val_integer'][0]) 140 | 141 | def testValidateIntegerMax(self): 142 | fd = self.fc.get_form_def('test_val_integer') 143 | form_values = {"val_integer": 7} 144 | errors, values = fd.validate(form_values) 145 | self.assertIn('val_integer', errors) 146 | self.assertIn('Maximum', errors['val_integer'][0]) 147 | 148 | def testValidateIntegerValue(self): 149 | fd = self.fc.get_form_def('test_val_integer') 150 | form_values = {"val_integer": 6} 151 | errors, values = fd.validate(form_values) 152 | self.assertNotIn('val_integer', errors) 153 | self.assertEqual(values['val_integer'], 6) 154 | 155 | def testValidateFloatInvalid(self): 156 | fd = self.fc.get_form_def('test_val_float') 157 | form_values = {"val_float": 'four'} 158 | errors, values = fd.validate(form_values) 159 | self.assertTrue('val_float' in errors) 160 | self.assertTrue('Must be a' in errors['val_float'][0]) 161 | 162 | def testValidateFloatMin(self): 163 | fd = self.fc.get_form_def('test_val_float') 164 | form_values = {"val_float": 2.05} 165 | errors, values = fd.validate(form_values) 166 | self.assertTrue('val_float' in errors) 167 | self.assertTrue('Minimum' in errors['val_float'][0]) 168 | 169 | def testValidateFloatMax(self): 170 | fd = self.fc.get_form_def('test_val_float') 171 | form_values = {"val_float": 2.31} 172 | errors, values = fd.validate(form_values) 173 | self.assertIn('val_float', errors) 174 | self.assertIn('Maximum', errors['val_float'][0]) 175 | 176 | def testValidateFloatValue(self): 177 | fd = self.fc.get_form_def('test_val_float') 178 | form_values = {"val_float": 2.29} 179 | errors, values = fd.validate(form_values) 180 | self.assertNotIn('val_float', errors) 181 | self.assertEqual(values['val_float'], 2.29) 182 | 183 | def testValidateDateInvalid(self): 184 | fd = self.fc.get_form_def('test_val_date') 185 | form_values = {"val_date": '2015-001'} 186 | errors, values = fd.validate(form_values) 187 | self.assertIn('val_date', errors) 188 | self.assertIn('Invalid date', errors['val_date'][0]) 189 | 190 | def testValidateDateMin(self): 191 | fd = self.fc.get_form_def('test_val_date') 192 | form_values = {"val_date": '2015-03-01'} 193 | errors, values = fd.validate(form_values) 194 | self.assertIn('val_date', errors) 195 | self.assertIn('Minimum', errors['val_date'][0]) 196 | 197 | def testValidateDateMax(self): 198 | fd = self.fc.get_form_def('test_val_date') 199 | form_values = {"val_date": '2015-03-06'} 200 | errors, values = fd.validate(form_values) 201 | self.assertIn('val_date', errors) 202 | self.assertIn('Maximum', errors['val_date'][0]) 203 | 204 | def testValidateDateValue(self): 205 | import datetime 206 | fd = self.fc.get_form_def('test_val_date') 207 | form_values = {"val_date": '2015-03-03'} 208 | errors, values = fd.validate(form_values) 209 | self.assertNotIn('val_date', errors) 210 | self.assertEqual(values['val_date'], datetime.date(2015, 3, 3)) 211 | 212 | def testValidateSelectValue(self): 213 | fd = self.fc.get_form_def('test_val_select') 214 | form_values = {"val_select": 'option_a'} 215 | errors, values = fd.validate(form_values) 216 | self.assertNotIn('val_select', errors) 217 | self.assertEqual(values['val_select'], 'option_a') 218 | 219 | def testValidateSelectInvalid(self): 220 | fd = self.fc.get_form_def('test_val_select') 221 | form_values = {"val_select": 'option_c'} 222 | errors, values = fd.validate(form_values) 223 | self.assertIn('val_select', errors) 224 | self.assertIn('Invalid value', errors['val_select'][0]) 225 | 226 | def testValidateCheckbox(self): 227 | fd = self.fc.get_form_def('test_val_checkbox') 228 | form_values = {"val_checkbox": 'on'} 229 | errors, values = fd.validate(form_values) 230 | self.assertNotIn('val_checkbox', errors) 231 | self.assertEqual(values['val_checkbox'], 'on') 232 | 233 | def testValidateCheckboxDefaultOn(self): 234 | fd = self.fc.get_form_def('test_val_checkbox_on') 235 | form_values = {"val_checkbox_on": 'off'} 236 | errors, values = fd.validate(form_values) 237 | self.assertNotIn('val_checkbox_on', errors) 238 | self.assertEqual(values['val_checkbox_on'], 'off') 239 | 240 | def testValidateCheckboxInvalid(self): 241 | fd = self.fc.get_form_def('test_val_checkbox') 242 | form_values = {"val_checkbox": 'true'} 243 | errors, values = fd.validate(form_values) 244 | self.assertIn('val_checkbox', errors) 245 | self.assertIn('Invalid value', errors['val_checkbox'][0]) 246 | 247 | def testValidateTextMin(self): 248 | fd = self.fc.get_form_def('test_val_text') 249 | form_values = {"val_text": '1234'} 250 | errors, values = fd.validate(form_values) 251 | self.assertIn('val_text', errors) 252 | self.assertIn('Minimum', errors['val_text'][0]) 253 | 254 | def testValidateTextMax(self): 255 | fd = self.fc.get_form_def('test_val_text') 256 | form_values = {"val_text": '12345678901'} 257 | errors, values = fd.validate(form_values) 258 | self.assertIn('val_text', errors) 259 | self.assertIn('Maximum', errors['val_text'][0]) 260 | 261 | def testValidateFileMissingFile(self): 262 | fd = self.fc.get_form_def('test_val_file') 263 | form_values = {} 264 | errors, values = fd.validate(form_values) 265 | self.assertIn('val_file', errors) 266 | self.assertIn('required', errors['val_file'][0]) 267 | 268 | def testValidateFileMissingFileName(self): 269 | fd = self.fc.get_form_def('test_val_file') 270 | form_values = {'val_file': 'foo'} 271 | self.assertRaises(KeyError, fd.validate, form_values) 272 | 273 | 274 | class FormDefinitionFieldMissingProperty(unittest.TestCase): 275 | """ 276 | """ 277 | def testMissing(self): 278 | self.assertRaises(KeyError, scriptform.ScriptForm, 'test_formdefinition_missing_title.json') 279 | 280 | 281 | class WebAppTest(unittest.TestCase): 282 | """ 283 | Test the web app by actually running the server and making web calls to it. 284 | """ 285 | @classmethod 286 | def setUpClass(cls): 287 | cls.auth_admin = requests.auth.HTTPBasicAuth('admin', 'admin') 288 | cls.auth_user = requests.auth.HTTPBasicAuth('user', 'user') 289 | 290 | # Run the server in a thread, so we can execute the tests in the main 291 | # program. 292 | def server_thread(sf): 293 | sf.run(listen_port=8002) 294 | cls.sf = scriptform.ScriptForm('test_webapp.json') 295 | 296 | thread = threading.Thread(target=server_thread, args=(cls.sf,)) 297 | thread.start() 298 | 299 | while True: 300 | time.sleep(0.1) 301 | if cls.sf.running is True: 302 | break 303 | 304 | @classmethod 305 | def tearDownClass(cls): 306 | # Shut down the webserver and wait until it has shut down. 307 | cls.sf.shutdown() 308 | while True: 309 | time.sleep(0.1) 310 | if cls.sf.running is False: 311 | break 312 | 313 | def testError404(self): 314 | r = requests.get('http://localhost:8002/nosuchurl') 315 | self.assertEqual(r.status_code, 404) 316 | self.assertIn('Not found', r.text) 317 | 318 | def testError401(self): 319 | r = requests.get('http://localhost:8002/') 320 | self.assertEqual(r.status_code, 401) 321 | 322 | def testAuthFormNoAuthGet(self): 323 | r = requests.get('http://localhost:8002/form?form_name=admin_only') 324 | self.assertEqual(r.status_code, 401) 325 | 326 | def testAuthFormNoAuthPost(self): 327 | data = {"form_name": 'admin_only'} 328 | r = requests.post('http://localhost:8002/submit', data) 329 | self.assertEqual(r.status_code, 401) 330 | 331 | def testAuthFormUnauthorizedGet(self): 332 | r = requests.get('http://localhost:8002/form?form_name=admin_only', auth=self.auth_user) 333 | self.assertEqual(r.status_code, 403) 334 | 335 | def testAuthFormUnauthorizedPost(self): 336 | data = {"form_name": 'admin_only'} 337 | r = requests.post('http://localhost:8002/submit', data, auth=self.auth_user) 338 | self.assertEqual(r.status_code, 403) 339 | 340 | def testHidden(self): 341 | """Hidden forms shouldn't appear in the output""" 342 | r = requests.get('http://localhost:8002/', auth=self.auth_user) 343 | self.assertNotIn('Hidden form', r.text) 344 | 345 | def testShown(self): 346 | """Non-hidden forms should appear in the output""" 347 | r = requests.get('http://localhost:8002/', auth=self.auth_user) 348 | self.assertIn('Output escaped', r.text) 349 | 350 | def testRender(self): 351 | r = requests.get('http://localhost:8002/form?form_name=validate', auth=self.auth_user) 352 | self.assertIn('Validated form', r.text) 353 | self.assertIn('This form is heavily validated', r.text) 354 | self.assertIn('name="string"', r.text) 355 | 356 | def testValidateCorrectData(self): 357 | data = { 358 | "form_name": 'validate', 359 | "string": "12345", 360 | "integer": "12", 361 | "float": "0.6", 362 | "date": "2015-01-02", 363 | "text": "1234567890", 364 | "password": "12345", 365 | "radio": "One", 366 | "checkbox": "on", 367 | "select": "option_a", 368 | } 369 | 370 | gen_random_file('data.csv') 371 | 372 | with open('data.csv', 'rb') as fh: 373 | files = {'file': fh} 374 | r = requests.post("http://localhost:8002/submit", data=data, files=files, auth=self.auth_user) 375 | 376 | self.assertIn('string=12345', r.text) 377 | self.assertIn('integer=12', r.text) 378 | self.assertIn('float=0.6', r.text) 379 | self.assertIn('date=2015-01-02', r.text) 380 | self.assertIn('text=1234567890', r.text) 381 | self.assertIn('password=12345', r.text) 382 | self.assertIn('radio=One', r.text) 383 | self.assertIn('checkbox=on', r.text) 384 | self.assertIn('select=option_a', r.text) 385 | 386 | os.unlink('data.csv') 387 | 388 | def testValidateIncorrectData(self): 389 | data = { 390 | "form_name": 'validate', 391 | "string": "12345678", 392 | "integer": "9", 393 | "float": "1.1", 394 | "date": "2015-02-02", 395 | "radio": "Ten", 396 | "text": "123456789", 397 | "password": "1234", 398 | "checkbox": "invalidvalue", 399 | "select": "invalidvalue", 400 | } 401 | 402 | gen_random_file('data.txt') 403 | 404 | with open('data.txt', 'rb') as fh: 405 | files = {'file': fh} 406 | r = requests.post("http://localhost:8002/submit", data=data, files=files, auth=self.auth_user) 407 | 408 | self.assertIn('Maximum length is 7', r.text) 409 | self.assertIn('Minimum value is 10', r.text) 410 | self.assertIn('Maximum value is 1.0', r.text) 411 | self.assertIn('Maximum value is 2015-02-01', r.text) 412 | self.assertIn('Invalid value for radio button: Ten', r.text) 413 | self.assertIn('Minimum length is 10', r.text) 414 | self.assertIn('Minimum length is 5', r.text) 415 | self.assertIn('Only file types allowed: csv', r.text) 416 | self.assertIn('Invalid value for radio button', r.text) 417 | self.assertIn('Invalid value for dropdown', r.text) 418 | 419 | os.unlink('data.txt') 420 | 421 | def testValidateRefill(self): 422 | """ 423 | Ensure that field values are properly repopulated if there were any 424 | errors in validation. 425 | """ 426 | data = { 427 | "form_name": 'validate', 428 | "string": "123", 429 | "integer": "12", 430 | "float": "0.6", 431 | "date": "2015-01-02", 432 | "text": "1234567890", 433 | "password": "12345", 434 | "radio": "One", 435 | "checkbox": "on", 436 | "select": "option_b", 437 | } 438 | 439 | gen_random_file('data.txt') 440 | 441 | with open ('data.txt', 'rb') as fh: 442 | files = {'file': fh} 443 | r = requests.post("http://localhost:8002/submit", data=data, files=files, auth=self.auth_user) 444 | self.assertIn('value="123"', r.text) 445 | self.assertIn('value="12"', r.text) 446 | self.assertIn('value="0.6"', r.text) 447 | self.assertIn('value="2015-01-02"', r.text) 448 | self.assertIn('>1234567890<', r.text) 449 | self.assertIn('value="12345"', r.text) 450 | self.assertIn('value="on"', r.text) 451 | self.assertIn('selected>Option B', r.text) 452 | 453 | os.unlink('data.txt') 454 | 455 | def testOutputEscaped(self): 456 | """Form with 'escaped' output should have HTML entities escaped""" 457 | data = { 458 | "form_name": 'output_escaped', 459 | "string": '' 460 | } 461 | r = requests.post('http://localhost:8002/submit', data, auth=self.auth_user) 462 | self.assertIn('string=<foo>', r.text) 463 | 464 | def testOutputRaw(self): 465 | data = { 466 | "form_name": 'output_raw', 467 | "string": '' 468 | } 469 | r = requests.post('http://localhost:8002/submit', data, auth=self.auth_user) 470 | self.assertIn('string=', r.text) 471 | 472 | def testOutputHTML(self): 473 | data = { 474 | "form_name": 'output_html', 475 | "string": '' 476 | } 477 | r = requests.post('http://localhost:8002/submit', data, auth=self.auth_user) 478 | self.assertIn('string=', r.text) 479 | 480 | def testUpload(self): 481 | gen_random_file('data.raw') 482 | 483 | data = { 484 | "form_name": "upload" 485 | } 486 | with open('data.raw', 'rb') as fh: 487 | files = {'file': fh} 488 | r = requests.post("http://localhost:8002/submit", files=files, data=data, auth=self.auth_user) 489 | self.assertIn('SAME', r.text) 490 | os.unlink('data.raw') 491 | 492 | def testStaticValid(self): 493 | r = requests.get("http://localhost:8002/static?fname=ssh_server.png", auth=self.auth_user) 494 | self.assertEqual(r.status_code, 200) 495 | f_served = b'' 496 | for c in r.iter_content(): 497 | f_served += c 498 | 499 | with open('static/ssh_server.png', 'rb')as fh: 500 | f_orig = fh.read() 501 | self.assertEqual(f_orig, f_served) 502 | 503 | def testStaticInvalidFilename(self): 504 | r = requests.get("http://localhost:8002/static?fname=../../ssh_server.png", auth=self.auth_user) 505 | self.assertEqual(r.status_code, 403) 506 | 507 | def testStaticInvalidNotFound(self): 508 | r = requests.get("http://localhost:8002/static?fname=nosuchfile.png", auth=self.auth_user) 509 | self.assertEqual(r.status_code, 404) 510 | 511 | def testHiddenField(self): 512 | r = requests.get('http://localhost:8002/form?form_name=hidden_field', auth=self.auth_user) 513 | self.assertIn('class="hidden"', r.text) 514 | 515 | def testCallbackFail(self): 516 | data = { 517 | "form_name": "callback_fail" 518 | } 519 | r = requests.post("http://localhost:8002/submit", data=data, auth=self.auth_user) 520 | self.assertIn('stderr output\n', r.text) 521 | 522 | 523 | class WebAppSingleTest(unittest.TestCase): 524 | """ 525 | Test that Scriptform doesn't show us a list of forms, but directly shows us 526 | the form is there's only one. 527 | """ 528 | @classmethod 529 | def setUpClass(cls): 530 | # Run the server in a thread, so we can execute the tests in the main 531 | # program. 532 | def server_thread(sf): 533 | sf.run(listen_port=8002) 534 | cls.sf = scriptform.ScriptForm('test_webapp_singleform.json') 535 | 536 | thread = threading.Thread(target=server_thread, args=(cls.sf,)) 537 | thread.start() 538 | 539 | while True: 540 | time.sleep(0.1) 541 | if cls.sf.running is True: 542 | break 543 | 544 | @classmethod 545 | def tearDownClass(cls): 546 | cls.sf.shutdown() 547 | while True: 548 | time.sleep(0.1) 549 | if not cls.sf.running: 550 | break 551 | 552 | def testSingleForm(self): 553 | """ 554 | Ensure that Scriptform directly shows the form if there is only one. 555 | """ 556 | r = requests.get("http://localhost:8002/") 557 | self.assertIn('only_form', r.text) 558 | 559 | def testStaticDisabled(self): 560 | """ 561 | """ 562 | r = requests.get("http://localhost:8002/static?fname=nosuchfile.png") 563 | self.assertEqual(r.status_code, 501) 564 | 565 | 566 | if __name__ == '__main__': 567 | logging.basicConfig(level=logging.FATAL, 568 | format='%(asctime)s:%(name)s:%(levelname)s:%(message)s', 569 | filename='test.log', 570 | filemode='a') 571 | import coverage 572 | cov = coverage.coverage(omit=['*test*', 'main', '*/lib/python*']) 573 | cov.start() 574 | 575 | sys.path.insert(0, '../src') 576 | import scriptform 577 | import runscript 578 | unittest.main(exit=True) 579 | 580 | cov.stop() 581 | cov.save() 582 | 583 | print(cov.report()) 584 | try: 585 | print(cov.html_report()) 586 | except coverage.misc.CoverageException as err: 587 | if "Couldn't find static file 'jquery.hotkeys.js'" in err.message: 588 | pass 589 | else: 590 | raise 591 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Script that's called by formconfig to test things. This is not the main 5 | # script to run tests. For that, see the "build.sla" file in the root dir of 6 | # this project. 7 | # 8 | 9 | cat << EOF 10 | HTTP/1.0 200 Ok 11 | echo "Content-type: text/plain" 12 | 13 | EOF 14 | env 15 | -------------------------------------------------------------------------------- /test/test_formconfig_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test", 6 | "title": "title", 7 | "description": "description", 8 | "script": "test.sh", 9 | "fields": [] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/test_formconfig_callback.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test_store", 6 | "title": "title", 7 | "description": "description", 8 | "script": "test_formconfig_callback.sh", 9 | "fields": [] 10 | }, 11 | { 12 | "name": "test_raw", 13 | "title": "title", 14 | "description": "description", 15 | "script": "test_formconfig_callback.sh", 16 | "output": "raw", 17 | "fields": [] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /test/test_formconfig_callback.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "stdout" 4 | echo "stderr" >&2 5 | 6 | exit 33 7 | -------------------------------------------------------------------------------- /test/test_formconfig_hidden.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test", 6 | "title": "title", 7 | "description": "description", 8 | "script": "test.sh", 9 | "hidden": true, 10 | "fields": [] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /test/test_formconfig_missingscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test", 6 | "title": "title", 7 | "description": "description", 8 | "script": "nonexisting.sh", 9 | "fields": [] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/test_formconfig_noexec.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test", 6 | "title": "title", 7 | "description": "description", 8 | "script": "test_noexec.sh", 9 | "fields": [] 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/test_formdefinition_missing_title.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test", 6 | "title": "title", 7 | "description": "description", 8 | "script": "test.sh", 9 | "fields": [ 10 | { 11 | "name": "string", 12 | "type": "string" 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /test/test_formdefinition_validate.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test_required", 6 | "title": "test_required", 7 | "description": "description", 8 | "script": "test.sh", 9 | "fields": [ 10 | { 11 | "name": "string", 12 | "title": "field", 13 | "type": "string", 14 | "required": true 15 | } 16 | ] 17 | }, 18 | { 19 | "name": "test_val_string", 20 | "title": "test_val_string", 21 | "description": "description", 22 | "script": "test.sh", 23 | "fields": [ 24 | { 25 | "name": "val_string", 26 | "title": "field", 27 | "type": "string", 28 | "minlen": 4, 29 | "maxlen": 6 30 | } 31 | ] 32 | }, 33 | { 34 | "name": "test_val_integer", 35 | "title": "test_val_integer", 36 | "description": "description", 37 | "script": "test.sh", 38 | "fields": [ 39 | { 40 | "name": "val_integer", 41 | "title": "field", 42 | "type": "integer", 43 | "min": 4, 44 | "max": 6 45 | } 46 | ] 47 | }, 48 | { 49 | "name": "test_val_float", 50 | "title": "test_val_float", 51 | "description": "description", 52 | "script": "test.sh", 53 | "fields": [ 54 | { 55 | "name": "val_float", 56 | "title": "field", 57 | "type": "float", 58 | "min": 2.1, 59 | "max": 2.3 60 | } 61 | ] 62 | }, 63 | { 64 | "name": "test_val_date", 65 | "title": "test_val_date", 66 | "description": "description", 67 | "script": "test.sh", 68 | "fields": [ 69 | { 70 | "name": "val_date", 71 | "title": "field", 72 | "type": "date", 73 | "min": "2015-03-02", 74 | "max": "2015-03-05" 75 | } 76 | ] 77 | }, 78 | { 79 | "name": "test_val_select", 80 | "title": "test_val_select", 81 | "description": "description", 82 | "script": "test.sh", 83 | "fields": [ 84 | { 85 | "name": "val_select", 86 | "title": "field", 87 | "type": "select", 88 | "options": [ 89 | ["option_a", "Option A"], 90 | ["option_b", "Option B"] 91 | ] 92 | } 93 | ] 94 | }, 95 | { 96 | "name": "test_val_checkbox", 97 | "title": "test_val_checkbox", 98 | "description": "description", 99 | "script": "test.sh", 100 | "fields": [ 101 | { 102 | "name": "val_checkbox", 103 | "title": "field", 104 | "type": "checkbox" 105 | } 106 | ] 107 | }, 108 | { 109 | "name": "test_val_checkbox_on", 110 | "title": "test_val_checkbox_on", 111 | "description": "description", 112 | "script": "test.sh", 113 | "fields": [ 114 | { 115 | "name": "val_checkbox", 116 | "title": "field", 117 | "type": "checkbox", 118 | "value": true 119 | } 120 | ] 121 | }, 122 | { 123 | "name": "test_val_text", 124 | "title": "test_val_text", 125 | "description": "description", 126 | "script": "test.sh", 127 | "fields": [ 128 | { 129 | "name": "val_text", 130 | "title": "field", 131 | "type": "text", 132 | "minlen": 5, 133 | "maxlen": 10 134 | } 135 | ] 136 | }, 137 | { 138 | "name": "test_val_file", 139 | "title": "test_val_file", 140 | "description": "description", 141 | "script": "test.sh", 142 | "fields": [ 143 | { 144 | "name": "val_file", 145 | "title": "field", 146 | "required": true, 147 | "type": "file" 148 | } 149 | ] 150 | } 151 | ] 152 | } 153 | -------------------------------------------------------------------------------- /test/test_noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | env 4 | -------------------------------------------------------------------------------- /test/test_scriptform_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "forms": [ 4 | { 5 | "name": "test_list_1", 6 | "title": "title_list_1", 7 | "description": "description", 8 | "script": "test.sh", 9 | "fields": [] 10 | }, 11 | { 12 | "name": "test_list_2", 13 | "title": "test_list_2", 14 | "description": "description", 15 | "script": "test.sh", 16 | "fields": [] 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /test/test_upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | MD5_UPLOAD=$(md5sum ${file} | cut -d" " -f1) 4 | MD5_ORIG=$(md5sum "data.raw" | cut -d" " -f1) 5 | 6 | if [ "$MD5_UPLOAD" = "$MD5_ORIG" ]; then 7 | echo "SAME" 8 | else 9 | echo "DIFFERENT" 10 | fi 11 | -------------------------------------------------------------------------------- /test/test_webapp.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Webapp test", 3 | "users": { 4 | "admin": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", 5 | "user": "04f8996da763b7a969b1028ee3007569eaf3a635486ddab211d512c85b9df8fb" 6 | }, 7 | "static_dir": "static", 8 | "forms": [ 9 | { 10 | "name": "admin_only", 11 | "title": "Admin only", 12 | "description": "Restricted form", 13 | "script": "test.sh", 14 | "hidden": "true", 15 | "fields": [], 16 | "allowed_users": ["admin"] 17 | }, 18 | { 19 | "name": "hidden", 20 | "title": "Hidden form", 21 | "description": "Hidden form", 22 | "script": "test.sh", 23 | "hidden": "true", 24 | "fields": [] 25 | }, 26 | { 27 | "name": "output_escaped", 28 | "title": "Output escaped", 29 | "description": "Output escaped", 30 | "script": "test.sh", 31 | "output": "escaped", 32 | "fields": [ 33 | { 34 | "name": "string", 35 | "title": "This string should be escaped in the output", 36 | "type": "string" 37 | } 38 | ] 39 | }, 40 | { 41 | "name": "output_raw", 42 | "title": "Output raw", 43 | "description": "Output raw", 44 | "script": "test.sh", 45 | "output": "raw", 46 | "fields": [ 47 | { 48 | "name": "string", 49 | "title": "This string should be raw in the output", 50 | "type": "string" 51 | } 52 | ] 53 | }, 54 | { 55 | "name": "output_html", 56 | "title": "Output html", 57 | "description": "Output html", 58 | "script": "test.sh", 59 | "output": "html", 60 | "fields": [ 61 | { 62 | "name": "string", 63 | "title": "This string should be unescaped in the output", 64 | "type": "string" 65 | } 66 | ] 67 | }, 68 | { 69 | "name": "validate", 70 | "title": "Validated form", 71 | "description": "This form is heavily validated", 72 | "submit_title": "Validate it", 73 | "script": "test.sh", 74 | "fields": [ 75 | { 76 | "name": "string", 77 | "title": "A string between 5 and 7 characters.", 78 | "type": "string", 79 | "required": true, 80 | "minlen": 5, 81 | "maxlen": 7 82 | }, 83 | { 84 | "name": "integer", 85 | "title": "An integer between 10 and 20", 86 | "type": "integer", 87 | "required": true, 88 | "min": 10, 89 | "max": 20 90 | }, 91 | { 92 | "name": "float", 93 | "title": "A real number between 0.5 and 1", 94 | "type": "float", 95 | "required": true, 96 | "min": 0.5, 97 | "max": 1.0 98 | }, 99 | { 100 | "name": "date", 101 | "title": "A date", 102 | "type": "date", 103 | "required": true, 104 | "min": "2015-01-01", 105 | "max": "2015-02-01" 106 | }, 107 | { 108 | "name": "radio", 109 | "title": "A radio", 110 | "type": "radio", 111 | "required": true, 112 | "options": [ 113 | ["One", "one"], 114 | ["Two", "two"], 115 | ["Three", "three"] 116 | ] 117 | }, 118 | { 119 | "name": "text", 120 | "title": "A text input field", 121 | "type": "text", 122 | "required": true, 123 | "rows": 2, 124 | "cols": 50, 125 | "maxlen": 100, 126 | "minlen": 10 127 | }, 128 | { 129 | "name": "password", 130 | "title": "A password input field", 131 | "type": "password", 132 | "required": true, 133 | "minlen": 5 134 | }, 135 | { 136 | "name": "select", 137 | "title": "A select input field", 138 | "type": "select", 139 | "options": [ 140 | ["option_a", "Option A"], 141 | ["option_b", "Option B"] 142 | ] 143 | }, 144 | { 145 | "name": "checkbox", 146 | "title": "A checkbox input field", 147 | "type": "checkbox" 148 | }, 149 | { 150 | "name": "file", 151 | "title": "A file upload field", 152 | "type": "file", 153 | "required": true, 154 | "extensions": ["csv"] 155 | } 156 | ] 157 | }, 158 | { 159 | "name": "upload", 160 | "title": "Upload", 161 | "description": "Upload", 162 | "script": "test_upload.sh", 163 | "fields": [ 164 | { 165 | "name": "file", 166 | "title": "File upload", 167 | "type": "file" 168 | } 169 | ] 170 | }, 171 | { 172 | "name": "hidden_field", 173 | "title": "Hidden field", 174 | "description": "Hidden field", 175 | "script": "test.sh", 176 | "fields": [ 177 | { 178 | "name": "Hidden class", 179 | "title": "This field has a 'hidden' class.", 180 | "type": "string", 181 | "hidden": true 182 | } 183 | ] 184 | }, 185 | { 186 | "name": "callback_fail", 187 | "title": "callback fail", 188 | "description": "Callback fail", 189 | "script": "test_webapp_cb_fail.sh", 190 | "fields": [] 191 | } 192 | ] 193 | } 194 | -------------------------------------------------------------------------------- /test/test_webapp_cb_fail.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "stdout output" 4 | echo "stderr output" >&2 5 | 6 | exit 1 7 | -------------------------------------------------------------------------------- /test/test_webapp_singleform.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "single form test", 3 | "forms": [ 4 | { 5 | "name": "only_form", 6 | "title": "Only form", 7 | "description": "Only form", 8 | "script": "test.sh", 9 | "fields": [] 10 | } 11 | ] 12 | } 13 | --------------------------------------------------------------------------------