Self hosted embedded mini-surveys
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.php 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. <?php
  2. require __DIR__ . '/vendor/autoload.php';
  3. ORM::configure("sqlite:" . __DIR__ . "/db.sqlite");
  4. /**
  5. * Class User
  6. * An administrative user.
  7. * @property int $id
  8. * @property string $username
  9. * @property string $password
  10. */
  11. class User extends Model
  12. {
  13. public static function hash_password($password)
  14. {
  15. return password_hash($password, PASSWORD_BCRYPT);
  16. }
  17. public function verify_password($password)
  18. {
  19. return password_verify($password, $this->password);
  20. }
  21. }
  22. /**
  23. * Class Question
  24. * @property int $id
  25. * @property int $user_id
  26. * @property string $title
  27. * @property string $description
  28. * @property string $type
  29. */
  30. class Question extends Model
  31. {
  32. public function configs()
  33. {
  34. return $this->has_many('QuestionConfig');
  35. }
  36. public function config($key)
  37. {
  38. return QuestionConfig::where('key', $key)->find_one();
  39. }
  40. public function responses()
  41. {
  42. switch ($this->type) {
  43. case 'freeform':
  44. case 'numeric':
  45. return FreeFormResponse::where('question_id', $this->id);
  46. case 'single':
  47. return ChoiceResponse::where('question_id', $this->id);
  48. }
  49. return null;
  50. }
  51. public function response($response_id)
  52. {
  53. switch ($this->type) {
  54. case 'freeform':
  55. case 'numeric':
  56. return FreeFormResponse::where('id', $response_id);
  57. case 'single':
  58. return ChoiceResponse::where('id', $response_id);
  59. }
  60. return null;
  61. }
  62. public function choices()
  63. {
  64. if ($this->type == 'freeform' || $this->type == 'numeric') return null;
  65. return $this->has_many('Choice');
  66. }
  67. public function choice($choice_id)
  68. {
  69. if ($this->type == 'freeform' || $this->type == 'numeric') return null;
  70. return $this->has_one('Choice');
  71. }
  72. }
  73. /**
  74. * Class QuestionConfig
  75. * @property int $id
  76. * @property int $question_id
  77. * @property string $key
  78. * @property string $value
  79. */
  80. class QuestionConfig extends Model
  81. {
  82. public function question()
  83. {
  84. return $this->belongs_to('Question');
  85. }
  86. }
  87. /**
  88. * Class Choice
  89. * @property int $id
  90. * @property int $question_id
  91. * @property string $value
  92. */
  93. class Choice extends Model
  94. {
  95. public function question()
  96. {
  97. return $this->belongs_to('Question');
  98. }
  99. }
  100. /**
  101. * Class Response
  102. * A generic response type extended by more specific types.
  103. */
  104. class Response extends Model
  105. {
  106. public function question()
  107. {
  108. return $this->belongs_to('Question');
  109. }
  110. }
  111. /**
  112. * Class FreeFormResponse
  113. * @property int $id
  114. * @property int $question_id
  115. * @property string $value
  116. */
  117. class FreeFormResponse extends Response
  118. {
  119. }
  120. /**
  121. * Class SingleChoiceResponse
  122. * @property int $id
  123. * @property int $question_id
  124. * @property int $choice_id
  125. */
  126. class ChoiceResponse extends Response
  127. {
  128. public function choice()
  129. {
  130. return Choice::where('id', $this->choice_id);
  131. }
  132. }
  133. $klein = new \Klein\Klein();
  134. $klein->respond(function ($request, $response, $service, $app) {
  135. $service->startSession();
  136. $app->register('user', function () {
  137. $user_id = @$_SESSION['USER_ID'];
  138. if (!$user_id) return false;
  139. try {
  140. return User::find_one($user_id);
  141. } catch (Exception $ex) {
  142. return false;
  143. }
  144. });
  145. $app->register('twig', function () use ($service, $app) {
  146. $loader = new Twig_Loader_Filesystem(__DIR__ . '/templates');
  147. $twig = new Twig_Environment($loader);
  148. $twig->addGlobal('user', $app->user);
  149. $twig->addGlobal('flashes', $service->flashes());
  150. $twig->addGlobal('session', $_SESSION);
  151. $twig->addGlobal('server', $_SERVER);
  152. return $twig;
  153. });
  154. });
  155. function hasBeenInstalled()
  156. {
  157. try {
  158. $user = User::find_one();
  159. if (!$user) return false;
  160. } catch (Exception $ex) {
  161. return false;
  162. }
  163. return true;
  164. }
  165. $klein->respond('GET', '/', function ($request, $response, $service, $app) {
  166. if (!hasBeenInstalled()) return $response->redirect('/admin/setup');
  167. return $app->twig->render('index.html');
  168. });
  169. $klein->with('/admin', function () use ($klein) {
  170. $klein->respond('GET', '', function ($request, $response, $service, $app) {
  171. if (!$app->user) return $response->redirect('/admin/login');
  172. $questions = Question::where('user_id', $app->user->id)
  173. ->order_by_asc('id')
  174. ->find_many();
  175. return $app->twig->render('admin/index.html', [
  176. 'questions' => $questions,
  177. ]);
  178. });
  179. $klein->respond('GET', '/setup', function ($request, $response, $service, $app) {
  180. if (hasBeenInstalled()) return $response->redirect('/admin');
  181. return $app->twig->render('admin/setup.html');
  182. });
  183. $klein->respond('POST', '/setup', function ($request, $response, $service, $app) {
  184. if (hasBeenInstalled()) return $response->redirect('/admin');
  185. $service->validateParam('username')->isLen(3, 24)->isChars('a-zA-Z0-9-');
  186. $service->validateParam('password')->isLen(10, 128);
  187. $sql = @file_get_contents(__DIR__ . '/install.sql');
  188. try {
  189. $db = ORM::get_db();
  190. $db->exec($sql);
  191. } catch (Exception $ex) {
  192. }
  193. $user = User::create();
  194. $user->username = $request->username;
  195. $user->password = User::hash_password($request->password);
  196. $user->save();
  197. $_SESSION['USER_ID'] = $user->id;
  198. return $response->redirect('/admin');
  199. });
  200. $klein->respond('GET', '/login', function ($request, $response, $service, $app) {
  201. if (!hasBeenInstalled()) return $response->redirect('/admin/setup');
  202. return $app->twig->render('admin/login.html');
  203. });
  204. $klein->respond('POST', '/login', function ($request, $response, $service, $app) {
  205. $service->validateParam('username')->notNull();
  206. $user = User::where('username', $request->username)->find_one();
  207. $_SESSION['username'] = $request->username;
  208. if (!$user) {
  209. $service->flash('Unknown username', 'error');
  210. return $response->redirect('/admin/login');
  211. }
  212. if (!$user->verify_password($request->password)) {
  213. $service->flash('Invalid password', 'error');
  214. return $response->redirect('/admin/login');
  215. }
  216. $_SESSION['USER_ID'] = $user->id;
  217. unset($_SESSION['username']);
  218. return $response->redirect('/admin');
  219. });
  220. $klein->respond('GET', '/logout', function ($request, $response, $service, $app) {
  221. unset($_SESSION['USER_ID']);
  222. return $response->redirect('/admin');
  223. });
  224. $klein->with('/question', function () use ($klein) {
  225. $klein->respond(function ($request, $response, $service, $app) {
  226. if (!$app->user) {
  227. $response->redirect('/admin/login');
  228. throw new \Klein\Exceptions\DispatchHaltedException();
  229. }
  230. });
  231. $klein->with('/[i:id]', function () use ($klein) {
  232. $klein->respond('GET', '', function ($request, $response, $service, $app) {
  233. $question = Question::where([
  234. 'id' => $request->id,
  235. 'user_id' => $app->user->id,
  236. ])->find_one();
  237. if (!$question) {
  238. $response->redirect('/admin');
  239. throw new \Klein\Exceptions\DispatchHaltedException();
  240. }
  241. return $app->twig->render('admin/question/view.html', [
  242. 'question' => $question,
  243. ]);
  244. });
  245. $klein->respond('POST', '', function ($request, $response, $service, $app) {
  246. $question = Question::where([
  247. 'id' => $request->id,
  248. 'user_id' => $app->user->id,
  249. ])->find_one();
  250. if (!$question) {
  251. return $response->redirect('/admin');
  252. }
  253. $question->title = $request->title;
  254. $question->description = $request->description;
  255. $question->save();
  256. return $response->redirect('/admin');
  257. });
  258. $klein->respond('POST', '/delete', function ($request, $response, $service, $app) {
  259. $question = Question::where([
  260. 'id' => $request->id,
  261. 'user_id' => $app->user->id,
  262. ])->find_one();
  263. if (!$question) {
  264. return $response->redirect('/admin');
  265. }
  266. $choices = $question->choices();
  267. if ($choices) {
  268. foreach ($choices->find_many() as $choice) {
  269. $choice->delete();
  270. }
  271. }
  272. foreach ($question->responses()->find_many() as $resp) {
  273. $resp->delete();
  274. }
  275. $question->delete();
  276. return $response->redirect('/admin');
  277. });
  278. $klein->with('/choice', function () use ($klein) {
  279. $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
  280. $choice = Choice::create();
  281. $question = Question::where([
  282. 'id' => $request->id,
  283. 'user_id' => $app->user->id,
  284. ])->find_one();
  285. if (!$question) {
  286. return $response->redirect('/admin');
  287. }
  288. $choice->question_id = $question->id;
  289. $choice->value = $request->value;
  290. $choice->save();
  291. return $response->redirect('/admin/question/' . $question->id);
  292. });
  293. $klein->with('/[i:cid]', function () use ($klein) {
  294. $klein->respond('GET', '', function ($request, $response, $service, $app) {
  295. $choice = Choice::find_one($request->cid);
  296. if (!$choice) {
  297. return $response->redirect('/admin');
  298. }
  299. $question = Question::where([
  300. 'id' => $choice->question()->find_one()->id,
  301. 'user_id' => $app->user->id,
  302. ])->find_one();
  303. if (!$question) {
  304. return $response->redirect('/admin');
  305. }
  306. return $app->twig->render('admin/question/choice/view.html', [
  307. 'choice' => $choice,
  308. ]);
  309. });
  310. $klein->respond('POST', '/delete', function ($request, $response, $service, $app) {
  311. $choice = Choice::find_one($request->cid);
  312. if (!$choice) {
  313. return $response->redirect('/admin');
  314. }
  315. $question = Question::where([
  316. 'id' => $choice->question()->find_one()->id,
  317. 'user_id' => $app->user->id,
  318. ])->find_one();
  319. if (!$question) {
  320. return $response->redirect('/admin');
  321. }
  322. foreach (ChoiceResponse::where('choice_id', $choice->id)->find_many() as $resp) {
  323. $resp->delete();
  324. }
  325. $choice->delete();
  326. return $response->redirect('/admin/question/' . $question->id);
  327. });
  328. });
  329. });
  330. $klein->with('/response', function () use ($klein) {
  331. $klein->with('/[i:rid]', function () use ($klein) {
  332. $klein->respond('GET', '', function ($request, $response, $service, $app) {
  333. $question = Question::find_one($request->id);
  334. if (!$question || $question->user_id != $app->user->id) return $response->redirect('/admin');
  335. $resp = $question->response($request->rid);
  336. if (!$resp) return $response->redirect('/admin');
  337. return $app->twig->render('admin/question/response/view.html', [
  338. 'question' => $question,
  339. 'response' => $resp,
  340. ]);
  341. });
  342. $klein->respond('POST', '/delete', function ($request, $response, $service, $app) {
  343. $question = Question::find_one($request->id);
  344. if (!$question || $question->user_id != $app->user->id) return $response->redirect('/admin');
  345. $resp = $question->response($request->rid)->find_one();
  346. $resp->delete();
  347. return $response->redirect('/admin/question/' . $question->id);
  348. });
  349. });
  350. $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
  351. $service->validateParam('value')->notNull();
  352. $question = Question::where([
  353. 'id' => $request->id,
  354. 'user_id' => $app->user->id,
  355. ])->find_one();
  356. if (!$question || $question->user_id != $app->user->id) return $response->redirect('/admin');
  357. switch ($question->type) {
  358. case 'freeform':
  359. $freeform = FreeFormResponse::create();
  360. $freeform->question_id = $question->id;
  361. $freeform->value = $request->value;
  362. $freeform->save();
  363. break;
  364. case 'numeric':
  365. $numeric = FreeFormResponse::create();
  366. $service->validateParam('value')->isInt();
  367. $numeric->question_id = $question->id;
  368. $numeric->value = $request->value;
  369. $numeric->save();
  370. break;
  371. case 'single':
  372. $single = ChoiceResponse::create();
  373. $single->question_id = $question->id;
  374. $single->choice_id = $request->value;
  375. $single->save();
  376. break;
  377. default:
  378. return 'invalid type';
  379. }
  380. return $response->redirect('/admin/question/' . $question->id);
  381. });
  382. });
  383. });
  384. $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
  385. $question = Question::create();
  386. $question->user_id = $app->user->id;
  387. $question->title = $request->title;
  388. $question->description = $request->description;
  389. $question->type = $request->type;
  390. $question->save();
  391. return $response->redirect('/admin');
  392. });
  393. });
  394. });
  395. $klein->with('/api/v1', function () use ($klein) {
  396. $klein->with('/question/[i:id]', function () use ($klein) {
  397. $klein->respond('GET', '', function ($request, $response, $service, $app) {
  398. $response->header('Access-Control-Allow-Origin', '*');
  399. $question = Question::find_one($request->id);
  400. if (!$question) {
  401. return $response->json(['error' => 'unknown question']);
  402. }
  403. $data = $question->as_array();
  404. if ($question->type == 'single') {
  405. $data['choices'] = $question->choices()->order_by_asc('id')->find_array();
  406. }
  407. return $response->json($data);
  408. });
  409. $klein->with('/response', function () use ($klein) {
  410. $klein->respond('GET', '', function ($request, $response, $service, $app) {
  411. $response->header('Access-Control-Allow-Origin', '*');
  412. $question = Question::find_one($request->id);
  413. if (!$question) {
  414. return $response->json([
  415. 'ok' => false,
  416. 'error' => 'unknown question'
  417. ]);
  418. }
  419. switch ($question->type) {
  420. case 'freeform':
  421. case 'numeric':
  422. return $response->json([
  423. 'responses' => $question->responses()->order_by_asc('id')->find_array(),
  424. ]);
  425. case 'single':
  426. $items = $question->responses()->order_by_asc('id')->find_many();
  427. $responses = [];
  428. foreach ($items as $item) {
  429. $data = $item->as_array();
  430. $data['choice'] = Choice::find_one($item->choice_id)->value;
  431. $responses[] = $data;
  432. }
  433. return $response->json([
  434. 'responses' => $responses,
  435. ]);
  436. default:
  437. return 'invalid type';
  438. }
  439. });
  440. $klein->respond('OPTIONS', '/add', function ($request, $response) {
  441. $response->header('Access-Control-Allow-Origin', '*');
  442. $response->header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  443. $response->header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
  444. return '';
  445. });
  446. $klein->respond('POST', '/add', function ($request, $response, $service, $app) {
  447. $response->header('Access-Control-Allow-Origin', '*');
  448. $response->header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  449. $json = json_decode($request->body());
  450. $question = Question::find_one($request->id);
  451. if (!$question) {
  452. return $response->json([
  453. 'ok' => false,
  454. 'error' => 'unknown question'
  455. ]);
  456. }
  457. switch ($question->type) {
  458. case 'freeform':
  459. if ($json->value == '') {
  460. return $response->json([
  461. 'ok' => false,
  462. 'error' => 'missing input',
  463. ]);
  464. }
  465. $freeform = FreeFormResponse::create();
  466. $freeform->question_id = $question->id;
  467. $freeform->value = $json->value;
  468. $freeform->save();
  469. break;
  470. case 'numeric':
  471. if ($json->value == '' || !is_numeric($json->value)) {
  472. return $response->json([
  473. 'ok' => false,
  474. 'error' => 'missing or invalid input',
  475. ]);
  476. }
  477. $numeric = FreeFormResponse::create();
  478. $numeric->question_id = $question->id;
  479. $numeric->value = $json->value;
  480. $numeric->save();
  481. break;
  482. case 'single':
  483. if ($json->value == '') {
  484. return $response->json([
  485. 'ok' => false,
  486. 'error' => 'missing input',
  487. ]);
  488. }
  489. $response_choice = Choice::where('question_id', $question->id)->where('id', $json->value)->find_one();
  490. if (!$response_choice) {
  491. return $response->json([
  492. 'ok' => false,
  493. 'error' => 'invalid choice',
  494. ]);
  495. }
  496. $choice = ChoiceResponse::create();
  497. $choice->question_id = $question->id;
  498. $choice->choice_id = $response_choice->id;
  499. $choice->save();
  500. break;
  501. default:
  502. return $response->json([
  503. 'ok' => false,
  504. 'error' => 'server misconfiguration',
  505. ]);
  506. }
  507. return $response->json([
  508. 'ok' => true,
  509. ]);
  510. });
  511. });
  512. });
  513. });
  514. $klein->respond('GET', '/peppershrike.js', function ($request, $response) {
  515. return $response->file(__DIR__ . '/peppershrike.js');
  516. });
  517. $klein->respond('GET', '/themes/[:theme]', function ($request, $response) {
  518. return $response->file(__DIR__ . '/themes/' . $request->theme, null, 'text/css');
  519. });
  520. $klein->dispatch();