├── .gitattributes
├── .gitignore
├── README.md
├── ai-post.php
├── api.md
├── api
├── image.php
├── imagelookup.php
├── post.php
├── postdata.php
├── postimages.php
├── reddit.php
├── source.php
├── tracking.php
└── user.php
├── app-config.sample.php
├── buckets.json
├── composer.json
├── config.sample.php
├── config
└── rewrites.json
├── controller
├── basepage.php
├── gallery.php
├── histogram.php
├── images.php
├── jsondataobject.php
├── page.php
├── queryoption.php
├── redditbooru.php
├── saucenao.php
├── stats.php
├── thumb.php
├── uas.php
└── uploadmanager.php
├── cron
├── ai-tan.php
├── clean.php
├── cron.php
└── repost_check.php
├── index.php
├── lib
├── aal.php
├── cache.php
├── dal.php
├── db.php
├── display.php
├── dxapi.php
├── events.php
├── ga.php
├── http.php
├── imageloader.php
├── redditoauth.php
├── serializexml.php
├── session.php
├── testbucket.php
├── url.php
└── util.php
├── package-lock.json
├── package.json
├── schema.sql
├── services
├── saucenao-request.js
├── saucenao-service.js
└── task-runner.js
├── sql
├── proc_GetLinkedPosts.sql
├── proc_UpdateDenormalizedPostData.sql
└── proc_UpdatePostDataSortHot.sql
├── static
├── js
│ ├── App.js
│ ├── HashRedirect.js
│ ├── controls
│ │ ├── Cookie.js
│ │ ├── GaTracker.js
│ │ ├── ProgressCircle.js
│ │ ├── Routes.js
│ │ ├── TplHelpers.js
│ │ ├── Uploader.js
│ │ └── overlay.js
│ ├── model
│ │ ├── Image.js
│ │ ├── ImageCollection.js
│ │ ├── QueryOption.js
│ │ └── QueryOptionCollection.js
│ └── view
│ │ ├── DragDropView.js
│ │ ├── FiltersView.js
│ │ ├── GalleryView.js
│ │ ├── ImageView.js
│ │ ├── ImageViewer.js
│ │ ├── MyGalleriesView.js
│ │ ├── NavView.js
│ │ ├── QueryOptionsView.js
│ │ ├── SearchView.js
│ │ ├── SidebarView.js
│ │ ├── UploadView.js
│ │ └── UserView.js
└── scss
│ ├── filters.scss
│ ├── filters.svg
│ ├── mixins.scss
│ ├── mobile.scss
│ ├── styles.scss
│ └── upload.scss
├── test
├── dal.php
├── harness.php
├── imageloader.php
├── post.php
├── postdata.php
├── postimages.php
└── test_db.php
├── tsconfig.json
├── upload.php
├── views
├── breadcrumb.hbs
├── gallery.hbs
├── imageSearchDetails.hbs
├── imageView.hbs
├── imagesRow.hbs
├── index.hbs
├── layouts
│ ├── main.hbs
│ ├── moesaic.hbs
│ ├── no_sources.hbs
│ └── upload.hbs
├── moreRow.hbs
├── partials
│ ├── dragdrop.hbs
│ ├── filters.hbs
│ ├── ga.hbs
│ ├── globalUploader.hbs
│ ├── imageViewer.hbs
│ ├── nav.hbs
│ ├── search.hbs
│ └── upload.hbs
├── queryOptionItem.hbs
├── uploadImageInfo.hbs
├── uploadRepost.hbs
├── uploading.hbs
├── userGalleries.hbs
└── userProfile.hbs
├── webpack.config.js
└── webpack.prod.js
/.gitattributes:
--------------------------------------------------------------------------------
1 | package-lock.json -diff
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # compiled output
4 | /dist
5 | /tmp
6 |
7 | # dependencies
8 | /node_modules
9 | /bower_components
10 | composer.lock
11 | /vendor
12 |
13 | # misc
14 | /.sass-cache
15 | /connect.lock
16 | /coverage/*
17 | /libpeerconnection.log
18 | npm-debug.log
19 | testem.log
20 | *.sublime*
21 | /.vscode
22 | *.log
23 |
24 | config.php
25 | app-config.php
26 |
27 | # build/redditbooru specific stuff
28 | static/fonts/
29 | static/images/
30 | static/scss/*.css
31 | experiments/
32 | cache/
33 |
34 | # OS X files
35 | ._*
36 | .DS_Store
37 |
--------------------------------------------------------------------------------
/api.md:
--------------------------------------------------------------------------------
1 | # RedditBooru API Endpoints
2 |
3 | ## Sources
4 | ----
5 | Returns a list of all enabled subreddit sources.
6 |
7 | * ***URL:*** `sources/`
8 |
9 | Note: the trailing '/' is required
10 |
11 | * ***Method:***
12 |
13 | GET
14 |
15 | * ***Success Response:***
16 |
17 | * ***Code:*** 200
18 | * ***Content:*** `[{ "name" : "r\/awwnime", "title":"awwnime","value":"1","checked":true }, ...`
19 |
20 | * ***Error Response:***
21 |
22 | The only error that should be returned is a 404 if the url was malformed, in which case it will return the default webpage
23 |
24 | * ***Sample Call:***
25 |
26 | `curl https://redditbooru.com/sources/`
27 |
28 |
29 |
30 |
31 | ## Images
32 | ----
33 | Returns images. If this is POSTed to with an image file, it will honor all of these settings while doing a reverse image search on the file supplied.
34 |
35 | * ***URL:*** `images/`
36 |
37 | * ***Method:***
38 |
39 | `GET, POST`
40 |
41 | * ***URL Params***
42 |
43 | ***Optional:***
44 |
45 |
46 | * `imageUri` -- URL to an image; returns visually similar results. _Default: empty_
47 |
48 | * `sources` -- a comma delimited list of sources to search through. If not passed, will default to source via subdomain, cookie, or source ID 1. Pass -1 to search all sources. _Default: empty_
49 |
50 | * `limit` -- The max number of images to return. _Default: 30_
51 |
52 | * `afterId` -- For paging; only fetches results after the image ID (currently buggy with albums). _Default: null_
53 |
54 | * `postId` -- Returns all images for a specific post ID. _Default: null_
55 |
56 | * `externalId` -- Returns all images for a specific reddit post ID. _Default: null_
57 |
58 | * `afterDate` -- Return only images posted after a specific unix timestamp. _Default: null_
59 |
60 | * `user` -- Return images posted by a specific user. _Default: null_
61 |
62 | * `q` -- Return images that match a keywords search query. _Default: null_
63 |
64 | * `ignoreSource` -- Ignore the default or provided sources. *DEPRECATED*
65 |
66 | * `ignoreUser` -- Ignore the provided user. *DEPRECATED*
67 |
68 | * `honorVisible` -- Don't show images that aren't marked visible. `True` by default. *DEPRECATED*
69 |
70 | * ***Success Response:***
71 |
72 | * ***Code:*** 200
73 | ***Content:*** _Varies with query._ Will most likely be an array of image objects
74 |
75 | * ***Error Response:***
76 |
77 | * ***Code:*** 500 INTERNAL SERVER Error
78 | ***Content:*** _none_
79 |
80 | OR
81 |
82 | * ***Code:*** 404 CONTENT NOT FOUND
83 | ***Content:*** _none_
84 |
85 | * ***Sample Call:***
86 |
87 | `curl https://redditbooru.com/api/images/?sources=1&limit=5&user=chiefnoah&q=alice`
88 |
89 | ### A Note About Sources
90 | Sources can be specified in a few ways with each having its own order of precedence, listed here from highest to lowest:
91 | - API call via source subdomain (eg: https://awwnime.redditbooru.com/images/)
92 | - Query string (comma delimited list or -1 for all)
93 | - Cookies
94 | - Default: source ID 1
95 |
--------------------------------------------------------------------------------
/api/imagelookup.php:
--------------------------------------------------------------------------------
1 | 'image_id',
16 | 'sourceId' => 'source_id',
17 | 'histR1' => 'image_hist_r1',
18 | 'histR2' => 'image_hist_r2',
19 | 'histR3' => 'image_hist_r3',
20 | 'histR4' => 'image_hist_r4',
21 | 'histG1' => 'image_hist_g1',
22 | 'histG2' => 'image_hist_g2',
23 | 'histG3' => 'image_hist_g3',
24 | 'histG4' => 'image_hist_g4',
25 | 'histB1' => 'image_hist_b1',
26 | 'histB2' => 'image_hist_b2',
27 | 'histB3' => 'image_hist_b3',
28 | 'histB4' => 'image_hist_b4',
29 | ];
30 |
31 | /**
32 | * ID of the image
33 | */
34 | public $imageId;
35 |
36 | /**
37 | * ID of the source
38 | */
39 | public $sourceId;
40 |
41 | /**
42 | * Red components
43 | */
44 | public $histR1;
45 | public $histR2;
46 | public $histR3;
47 | public $histR4;
48 |
49 | /**
50 | * Green components
51 | */
52 | public $histG1;
53 | public $histG2;
54 | public $histG3;
55 | public $histG4;
56 |
57 | /**
58 | * Blue components
59 | */
60 | public $histB1;
61 | public $histB2;
62 | public $histB3;
63 | public $histB4;
64 |
65 | private static $_tablePrefix = 'img_lookup_';
66 |
67 | /**
68 | * Table shards
69 | */
70 | private static $_shards = [
71 | 'r1', 'r2', 'r3', 'r4',
72 | 'g1', 'g2', 'g3', 'g4',
73 | 'b1', 'b2', 'b3', 'b4'
74 | ];
75 |
76 | /**
77 | * Creates a lookup entry for an image with a source
78 | */
79 | public static function syncLookupEntry(Image $image, Source $source) {
80 | $obj = new ImageLookup();
81 |
82 | $obj->imageId = $image->id;
83 | $obj->sourceId = $source->id;
84 | foreach (self::$_shards as $shard) {
85 | $prop = 'hist' . strtoupper($shard);
86 | $obj->$prop = $image->$prop;
87 | }
88 |
89 | $obj->_dbTable = self::_getTableShardForImage($image);
90 |
91 | return $obj->sync();
92 | }
93 |
94 | /**
95 | * Searches for posts by image and returns an array of matching image IDs and their Euclidian
96 | * distance to the original.
97 | */
98 | public static function reverseLookup(Image $image, array $sources = [], $count = 5) {
99 |
100 | $tableName = self::_getTableShardForImage($image);
101 | header('X-Lookup-Shard: ' . $tableName);
102 |
103 | $query = 'SELECT image_id';
104 |
105 | $params = [];
106 | $query .= ', (';
107 | for ($i = 1; $i <= HISTOGRAM_BUCKETS; $i++) {
108 | $prop = 'histR' . $i;
109 | $params[':red' . $i] = $image->$prop;
110 | $prop = 'histG' . $i;
111 | $params[':green' . $i] = $image->$prop;
112 | $prop = 'histB' . $i;
113 | $params[':blue' . $i] = $image->$prop;
114 | $query .= 'ABS(`image_hist_r' . $i . '` - :red' . $i . ') + ABS(`image_hist_g' . $i . '` - :green' . $i . ') + ABS(`image_hist_b' . $i . '` - :blue' . $i . ') + ';
115 | }
116 | $query .= ' 0) AS distance';
117 |
118 | $query .= ' FROM `' . $tableName . '` ';
119 |
120 | $where = [];
121 |
122 | if ($sources) {
123 | $tmpList = [];
124 | $i = 0;
125 | foreach ($sources as $source) {
126 | $params[':source' . $i] = $source;
127 | $tmpList[] = ':source' . $i;
128 | $i++;
129 | }
130 | $where[] = 'source_id IN (' . implode(',', $tmpList) . ')';
131 | }
132 |
133 | if (count($where)) {
134 | $query .= 'WHERE ' . implode(' AND ', $where) . ' ';
135 | }
136 |
137 | $query .= 'ORDER BY distance LIMIT ' . ($count * 2);
138 | $startTime = microtime(true);
139 | $result = Lib\Db::Query($query, $params);
140 | header('X-Lookup-Time: ' . (microtime(true) - $startTime));
141 |
142 | $time = time();
143 | $retVal = [];
144 | if ($result && $result->count) {
145 | while($row = Lib\Db::Fetch($result)) {
146 | $retVal[] = (object)[
147 | 'imageId' => (int) $row->image_id,
148 | 'distance' => (float) $row->distance
149 | ];
150 | }
151 | }
152 |
153 | foreach ($params as $key => $val) {
154 | $query = str_replace($key, $val, $query);
155 | }
156 |
157 | return $retVal;
158 | }
159 |
160 | /**
161 | * Determines which table shard to use for the passed image
162 | */
163 | private static function _getTableShardForImage(Image $image) {
164 | $max = -1;
165 | $maxShard = '';
166 |
167 | foreach (self::$_shards as $shard) {
168 | $prop = 'hist' . strtoupper($shard);
169 | if ($image->$prop > $max) {
170 | $max = $image->$prop;
171 | $maxShard = $shard;
172 | }
173 | }
174 |
175 | return self::$_tablePrefix . $maxShard;
176 | }
177 |
178 | }
179 |
180 | }
--------------------------------------------------------------------------------
/api/postimages.php:
--------------------------------------------------------------------------------
1 | $post->id ];
19 |
20 | for ($i = 0, $count = count($images); $i < $count; $i++) {
21 | $values[] = '(:image' . $i . ', :postId)';
22 | $params[':image' . $i] = $images[$i]->id;
23 | }
24 |
25 | $query = 'INSERT INTO post_images VALUES ' . implode(', ', $values);
26 | return null !== Lib\Db::Query($query, $params);
27 | }
28 |
29 | /**
30 | * Returns all images associated to a post
31 | * @param Post $post Post object to fetch images for
32 | * @return array Array of image objects associated to the post
33 | */
34 | public static function getImagesForPost(Post $post) {
35 | return Lib\Cache::getInstance()->fetch(function() use ($post) {
36 | $query = 'SELECT i.* FROM `post_images` pi INNER JOIN `images` i ON i.`image_id` = pi.`image_id` WHERE pi.`post_id` = :postId';
37 | $result = Lib\Db::Query($query, [ ':postId' => $post->id ]);
38 | $retVal = [];
39 |
40 | if ($result && $result->count) {
41 | while ($row = Lib\Db::Fetch($result)) {
42 | $retVal[] = new Image($row);
43 | }
44 | }
45 |
46 | return $retVal;
47 |
48 | }, 'Api:PostImages:getImagesForPost_' . $post->id, CACHE_LONG);
49 | }
50 |
51 | /**
52 | * Deletes all current associations for a post and then recreates them from the provided array of image IDs
53 | */
54 | public static function rebuildPostAssociations($images, Post $post) {
55 | Lib\Db::Query('DELETE FROM `post_images` WHERE `post_id` = :postId', [ ':postId' => $post->id ]);
56 | return self::assignImagesToPost($images, $post);
57 | }
58 |
59 | }
60 |
61 | }
--------------------------------------------------------------------------------
/api/source.php:
--------------------------------------------------------------------------------
1 | 'source_id',
17 | 'name' => 'source_name',
18 | 'baseUrl' => 'source_baseurl',
19 | 'type' => 'source_type',
20 | 'enabled' => 'source_enabled',
21 | 'subdomain' => 'source_subdomain',
22 | 'contentRating' => 'source_content_rating',
23 | 'repostCheck' => 'source_repost_check'
24 | );
25 |
26 | /**
27 | * Database table name
28 | */
29 | protected $_dbTable = 'sources';
30 |
31 | /**
32 | * Table primary key
33 | */
34 | protected $_dbPrimaryKey = 'id';
35 |
36 | /**
37 | * ID of the source
38 | */
39 | public $id = 0;
40 |
41 | /**
42 | * Name of the source
43 | */
44 | public $name;
45 |
46 | /**
47 | * URL of the source media
48 | */
49 | public $baseUrl;
50 |
51 | /**
52 | * Source type
53 | */
54 | public $type;
55 |
56 | /**
57 | * Source type
58 | */
59 | public $enabled;
60 |
61 | /**
62 | * Associated subdomain
63 | */
64 | public $subdomain;
65 |
66 | /**
67 | * Content rating
68 | */
69 | public $contentRating;
70 |
71 | /**
72 | * If non-zero, the number of seconds in which a repost is banned for this sub
73 | */
74 | public $repostCheck;
75 |
76 | /**
77 | * Constructor
78 | * @param $obj mixed Data to construct object around
79 | */
80 | public function __construct($obj = null) {
81 | if ($obj instanceOf Source) {
82 | __copy($obj);
83 | } else if (is_object($obj)) {
84 | $this->copyFromDbRow($obj);
85 | }
86 | }
87 |
88 | private function __copy($obj) {
89 | if ($obj instanceOf Source) {
90 | $this->id = $obj->id;
91 | $this->name = $obj->name;
92 | $this->baseUrl = $obj->baseUrl;
93 | $this->type = $obj->type;
94 | }
95 | }
96 |
97 | /**
98 | * XML serializer
99 | */
100 | public function __serialize() {
101 | $retVal = '';
102 | $retVal .= 'name . ']]>';
103 | $retVal .= '' . $this->baseUrl . '';
104 | $retVal .= '';
105 | return $retVal;
106 | }
107 |
108 | /**
109 | * Returns all sources
110 | * TODO: Unused. Delete.
111 | */
112 | public static function getAllEnabled() {
113 |
114 | $cacheKey = 'Source_getAllEnabled';
115 | return Lib\Cache::getInstance()->fetch(function() {
116 | $retVal = null;
117 | $result = Lib\Db::Query('SELECT * FROM `sources` WHERE source_enabled = 1');
118 | if (null != $result && $result->count > 0) {
119 | $retVal = [];
120 | while ($row = Lib\Db::Fetch($result)) {
121 | $retVal[] = new Source($row);
122 | }
123 | }
124 | return $retVal;
125 | }, $cacheKey);
126 |
127 | }
128 |
129 | /**
130 | * Returns a source by subdomain
131 | */
132 | public static function getBySubdomain($vars) {
133 | $domain = Lib\Url::Get('domain', null, $vars);
134 | return Lib\Cache::getInstance()->fetch(function() use ($domain) {
135 | $result = Lib\Db::Query('SELECT * FROM `sources` WHERE source_subdomain = :domain', [ 'domain' => $domain ]);
136 | $retVal = null;
137 | if (null != $result && $result->count > 0) {
138 | $retVal = new Source(Lib\Db::Fetch($result));
139 | }
140 | return $retVal;
141 | }, 'Source::getBySubdomain_' . $domain, 3600);
142 | }
143 |
144 | /**
145 | * Returns any sources on the query string in an array defaulting to cookie when not present
146 | */
147 | public static function getSourcesFromQueryString() {
148 | $sources = Lib\Url::Get(QS_SOURCES, null);
149 | if (null !== $sources) {
150 | $sources = explode(',', $sources);
151 | } else {
152 | if (isset($_COOKIE[QS_SOURCES])) {
153 | $sources = explode(',', $_COOKIE[QS_SOURCES]);
154 | } else {
155 | $sources = [ 1 ]; // final default is awwnime
156 | }
157 | }
158 |
159 | return $sources;
160 |
161 | }
162 |
163 | public static function formatSourceName($name) {
164 | return strpos($name, 'r/') === 0 ? substr($name, 2) : $name;
165 | }
166 |
167 | }
168 |
169 | }
--------------------------------------------------------------------------------
/api/tracking.php:
--------------------------------------------------------------------------------
1 | 'tracking_event',
12 | 'date' => 'tracking_date',
13 | 'data' => 'tracking_data'
14 | ];
15 |
16 | /**
17 | * The name of the tracking event
18 | */
19 | public $event;
20 |
21 | /**
22 | * The unix timestamp the event was logged
23 | */
24 | public $date;
25 |
26 | /**
27 | * Data pertaining to the tracking event
28 | */
29 | public $data;
30 |
31 | /**
32 | * Logs a tracking event
33 | *
34 | * @param string $eventName The name of the event
35 | * @param object $data Any data associated with the tracking event to log
36 | * @return boolean Success of the tracking log
37 | */
38 | public static function trackEvent($eventName, $data = null) {
39 | $event = new Tracking();
40 | $event->event = $eventName;
41 | $event->date = time();
42 | $event->data = json_encode($data);
43 | return $event->sync();
44 | }
45 |
46 | }
47 |
48 | }
--------------------------------------------------------------------------------
/app-config.sample.php:
--------------------------------------------------------------------------------
1 | =1.0.0",
14 | "ramsey/uuid": "^3.9"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/config.sample.php:
--------------------------------------------------------------------------------
1 | id);
24 | Lib\Display::addKey('csrfToken', $user->csrfToken);
25 | }
26 |
27 | Lib\Display::addKey('phpSessionUpload', ini_get("session.upload_progress.name"));
28 |
29 | // Get sources
30 | $sources = QueryOption::getSources();
31 | $enabledSources = [];
32 |
33 | // If there are no sources, kill everything now and show a friendly "no sources" page
34 | if (!$sources || !count($sources)) {
35 | Lib\Display::setLayout('no_sources');
36 | Lib\Display::render();
37 | exit;
38 | }
39 |
40 | // nsfw display flag
41 | Lib\Display::addKey('showNsfw', Lib\Url::GetBool('showNsfw', $_COOKIE));
42 |
43 | // If there were sources passed on the query string, use those for image fetchery. Fall back on cookies
44 | $qsSources = Lib\Url::Get('sources', null);
45 | if ($qsSources) {
46 | $enabledSources = explode(',', $qsSources);
47 | } else {
48 | foreach ($sources as $source) {
49 | if ($source->checked) {
50 | $enabledSources[] = $source->value;
51 | }
52 | }
53 | }
54 | self::$enabledSources = $enabledSources;
55 |
56 | self::addTestToOutput('enableMobile');
57 | self::addTestToOutput('showRedditControls');
58 | self::addTestToOutput('sourceFinder');
59 |
60 | Lib\Display::addKey('sources', $sources);
61 | Lib\Display::addKey('tests', self::$tests);
62 |
63 | }
64 |
65 | protected static function setOgData($title, $image) {
66 | $ogData = new stdClass;
67 | $ogData->title = $title;
68 | $ogData->image = $image;
69 | $ogData->url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
70 | Lib\Display::addKey('ogData', $ogData);
71 | }
72 |
73 | protected static function addTestToOutput($key) {
74 | self::$tests->$key = Lib\TestBucket::get($key);
75 | }
76 |
77 | protected static function getPageSubdomain() {
78 | $retVal = false;
79 | if (isset($_GET['domain']) && preg_match('/[\w]+/i', $_GET['domain'])) {
80 | $retVal = $_GET['domain'];
81 | } else if (preg_match('/([\w]+)\.(redditbooru|awwni)\.[\w]{2,3}/is', $_SERVER['HTTP_HOST'], $matches)) {
82 | $retVal = $matches[1];
83 | }
84 | return $retVal;
85 | }
86 |
87 | }
88 |
89 | }
--------------------------------------------------------------------------------
/controller/gallery.php:
--------------------------------------------------------------------------------
1 | fetch(function() use ($id) {
35 | $post = Api\Post::query([ 'externalId' => $id ]);
36 | $retVal = null;
37 | if ($post && $post->count) {
38 | $row = new Api\Post(Lib\Db::Fetch($post));
39 | $retVal = $row->id;
40 | }
41 | return $retVal;
42 | }, 'Controller:Gallery:_getFromRedditId_' . $id);
43 | }
44 |
45 | private static function _displayGallery() {
46 | $new = Lib\Url::GetBool('new');
47 | $id = Lib\Url::Get('post', null);
48 | $from = Lib\Url::Get('from', null);
49 | $id = $new ? base_convert($id, 36, 10) : $id;
50 |
51 | // If coming from a reddit URL, get the post ID
52 | if ($from === 'reddit') {
53 | $id = self::_getFromRedditId($id);
54 | }
55 |
56 | if (is_numeric($id)) {
57 | $post = Api\Post::getById($id);
58 | if ($post->id != $id || !$post->visible) {
59 | header('HTTP/1.0 404 Not Found');
60 | exit;
61 | }
62 | $retVal = Api\PostData::getGallery($id, $from === 'reddit');
63 | }
64 |
65 | if (count($retVal)) {
66 | Lib\Display::addKey('title', $retVal[0]->title);
67 | Lib\Display::addKey('pageTitle', $retVal[0]->title . ' - redditbooru');
68 | self::setOgData($retVal[0]->title, $retVal[0]->cdnUrl);
69 | }
70 |
71 | self::$renderKeys['images'] = [
72 | 'view' => 'gallery',
73 | 'images' => $retVal
74 | ];
75 | Lib\Display::addKey('imagesDisplay', 'gallery');
76 | Lib\Display::renderAndAddKey('body', 'index', self::$renderKeys);
77 | }
78 |
79 | private static function _userGalleries() {
80 | $user = Api\User::getCurrentUser();
81 | if (!$user) {
82 | // TODO - internal redirect
83 | header('Redirect: /login/');
84 | exit;
85 | }
86 |
87 | $page = Lib\Url::GetInt('p', 1);
88 | $galleries = Api\Post::getUserGalleries($user->id, $page);
89 | self::$renderKeys['galleries'] = $galleries->results;
90 | $userProfile = Api\PostData::getUserProfile($user->name);
91 |
92 | // Build up the paging links
93 | $currentPage = $galleries->paging->current;
94 | $pages = [];
95 | for ($i = 0; $i < $galleries->paging->total; $i++) {
96 | $pages[] = [
97 | 'page' => $i + 1,
98 | 'current' => ($i + 1) === $currentPage
99 | ];
100 | }
101 | self::$renderKeys['paging'] = [
102 | 'hasPrev' => $currentPage > 1,
103 | 'hasNext' => $currentPage < $galleries->paging->total,
104 | 'next' => $currentPage + 1,
105 | 'prev' => $currentPage - 1,
106 | 'pages' => $pages
107 | ];
108 |
109 | Lib\Display::addKey('title', 'My Galleries');
110 | Lib\Display::renderAndAddKey('supporting', 'userProfile', $userProfile);
111 | Lib\Display::renderAndAddKey('body', 'userGalleries', self::$renderKeys);
112 |
113 | }
114 |
115 | }
116 |
117 | }
118 |
--------------------------------------------------------------------------------
/controller/histogram.php:
--------------------------------------------------------------------------------
1 | 'histR',
27 | 'color' => imagecolorallocate($imgOut, 255, 0, 0)
28 | ],
29 | [
30 | 'prop' => 'histG',
31 | 'color' => imagecolorallocate($imgOut, 0, 255, 0)
32 | ],
33 | [
34 | 'prop' => 'histB',
35 | 'color' => imagecolorallocate($imgOut, 0, 0, 255)
36 | ]
37 | ];
38 | foreach ($dataSet as $item) {
39 | $prop = $item['prop'] . '1';
40 | $color = $item['color'];
41 | $lastY = round($image->$prop * self::IMAGE_HEIGHT);
42 | for ($i = 2; $i < 5; $i++) {
43 | $prop = $item['prop'] . $i;
44 | $y = round($image->$prop * self::IMAGE_HEIGHT);
45 | imageline($imgOut, ($i - 2) * $step, self::IMAGE_HEIGHT - $lastY, ($i - 1) * $step, self::IMAGE_HEIGHT - $y, $color);
46 | $lastY = $y;
47 | }
48 | }
49 | header('Content-Type: image/png');
50 | imagepng($imgOut);
51 | exit;
52 | } else {
53 | http_response_code(404);
54 | exit;
55 | }
56 |
57 | }
58 |
59 | }
60 |
61 | }
--------------------------------------------------------------------------------
/controller/jsondataobject.php:
--------------------------------------------------------------------------------
1 | rowId = (int) $row->image_id;
108 | $this->sourceId = (int) $row->source_id;
109 | $this->postId = (int) $row->post_id;
110 | $this->cdnUrl = $row->image_cdn_url;
111 | $this->width = (int) $row->image_width;
112 | $this->height = (int) $row->image_height;
113 | $this->title = $row->post_title;
114 | $this->dateCreated = (int) $row->post_date;
115 | $this->age = time() - $this->dateCreated;
116 | $this->externalId = $row->post_external_id;
117 | $this->score = (int) $row->post_score;
118 | $this->userName = isset($row->user_name) ? $row->user_name : null;
119 | $this->userId = isset($row->user_id) ? (int) $row->user_id : null;
120 | $this->baseUrl = isset($row->source_baseurl) ? $row->source_baseurl : null;
121 | $this->sourceName = isset($row->source_name) ? $row->source_name : null;
122 | $this->thumb = Thumb::createThumbFilename($this->cdnUrl);
123 | $this->idxInAlbum = isset($row->count) ? (int) $row->count : null;
124 | $this->nsfw = isset($row->source_content_rating) ? (int) $row->source_content_rating !== 0 : false;
125 | }
126 | }
127 |
128 | }
129 |
130 | }
--------------------------------------------------------------------------------
/controller/page.php:
--------------------------------------------------------------------------------
1 | name = $obj->name;
20 | $this->title = str_replace('r/', '', $this->name);
21 | $this->value = $obj->id;
22 | }
23 | }
24 |
25 | public static function render() {
26 | $action = Lib\Url::Get('action', null);
27 | $retVal = null;
28 | switch ($action) {
29 | case 'sources':
30 | $retVal = self::getSources();
31 | break;
32 | }
33 | header('Content-Type: text/javascript');
34 | echo json_encode($retVal);
35 | exit;
36 | }
37 |
38 | /**
39 | * Returns any sources on the query string in an array defaulting to cookie when not present
40 | */
41 | public static function getSources() {
42 |
43 | // First, get all enabled sources from the database
44 | $sources = Lib\Cache::getInstance()->fetch(function() {
45 | return Api\Source::queryReturnAll([ 'enabled' => 1, 'type' => 'subreddit' ], [ 'name' => 'asc' ]);
46 | }, 'Controller_QueryOption_getSources', CACHE_LONG);
47 |
48 | $retVal = [];
49 | $cookieSources = isset($_COOKIE[QS_SOURCES]) ? explode(',', $_COOKIE[QS_SOURCES]) : [ 1 ];
50 |
51 | // Create QueryOption objects for the outgoing items and check anything that's in the user cookie
52 | if ($sources) {
53 | foreach($sources as $source) {
54 | $obj = new QueryOption($source);
55 | $obj->checked = array_search($source->id, $cookieSources) !== false;
56 | $retVal[] = $obj;
57 | }
58 | }
59 |
60 | return $retVal;
61 | }
62 |
63 | }
64 |
65 | }
--------------------------------------------------------------------------------
/controller/saucenao.php:
--------------------------------------------------------------------------------
1 | results) && count($data->results)) {
22 | $result = $data->results[0];
23 | if ($result->header->similarity >= self::MIN_SIMILARITY) {
24 | $data = [
25 | 'title' => $result->data->title . ' drawn by ' . $result->data->member_name,
26 | 'url' => str_replace('{{ID}}', $result->data->pixiv_id, self::PIXIV_LINK)
27 | ];
28 | }
29 | } else {
30 | $data = null;
31 | }
32 |
33 | echo json_encode($data);
34 | }
35 |
36 | }
37 |
38 | }
--------------------------------------------------------------------------------
/controller/thumb.php:
--------------------------------------------------------------------------------
1 | data);
76 | $image = new Imagick($tmpFile);
77 | $image->setImageBackgroundColor('white');
78 | $image = $image->flattenImages();
79 | unlink($tmpFile);
80 |
81 | if ($image) {
82 |
83 | $width = $width > 0 ? $width : false;
84 | $height = $height > 0 ? $height : false;
85 |
86 | // The scheme for URL naming is set so that all the parameters can be inferred from the file name. They are
87 | // - URL of file, base64 encoded with trailing == removed
88 | // - height and width of thumbnail
89 | $encodedUrl = self::createThumbFilename($url);
90 | $outFile = getcwd() . $encodedUrl . '_' . $width . '_' . $height . '.jpg';
91 |
92 | if ($image->getNumberImages() > 0) {
93 | foreach ($image as $frame) {
94 | $image = $frame;
95 | break;
96 | }
97 | }
98 |
99 | $image->cropThumbnailImage($width, $height);
100 | $image->setFormat('JPEG');
101 | $image->writeImage($outFile);
102 | header('Content-Type: image/jpeg');
103 | readfile($outFile);
104 | exit;
105 | }
106 |
107 |
108 | } else {
109 | // redirect to standard "error" image
110 | }
111 | }
112 |
113 | public static function passThrough($url) {
114 | $image = Lib\ImageLoader::fetchImage($url);
115 | if ($image) {
116 | $contentType = 'image/';
117 | switch ($image->type) {
118 | case IMAGE_TYPE_JPEG:
119 | $contentType .= 'jpeg';
120 | break;
121 | case IMAGE_TYPE_GIF:
122 | case IMAGE_TYPE_PNG:
123 | $contentType .= $image->type;
124 | }
125 |
126 | file_put_contents('.' . self::createThumbFilename($url) . '.jpg', $image->data);
127 |
128 | header('Content-Type: ' . $contentType);
129 | echo $image->data;
130 | exit;
131 | }
132 | }
133 |
134 | }
135 |
136 | }
137 |
--------------------------------------------------------------------------------
/controller/uas.php:
--------------------------------------------------------------------------------
1 | logout();
29 | }
30 | header('Location: /');
31 | exit;
32 | case 'authenticate':
33 | $code = Lib\Url::Get('code');
34 | if (false !== $code) {
35 | Api\User::authenticateUser($code);
36 | header('Location: /');
37 | exit;
38 | }
39 | break;
40 | case 'vote':
41 | self::_vote();
42 | exit;
43 | }
44 |
45 | }
46 |
47 | private static function _vote() {
48 | $user = Api\User::getCurrentUser();
49 | if ($user) {
50 | $csrfToken = Lib\Url::Get('csrfToken', null);
51 | if ($csrfToken === $user->csrfToken) {
52 | $user->vote(Lib\Url::Get('id'), Lib\Url::GetInt('dir'));
53 | }
54 | }
55 | }
56 |
57 | }
58 |
59 | }
--------------------------------------------------------------------------------
/controller/uploadmanager.php:
--------------------------------------------------------------------------------
1 | error = true;
52 | if (is_numeric($uploadId)) {
53 | $data->uploadId = $uploadId;
54 | $file = $_FILES['upload'];
55 | $fileName = self::getUploadedFilePath($uploadId);
56 |
57 | if (is_uploaded_file($file['tmp_name']) && move_uploaded_file($file['tmp_name'], $fileName)) {
58 | if ($moveOnly) {
59 | $data->fileName = $fileName;
60 | $data->error = false;
61 | } else {
62 | $data = self::_handleImageUpload($fileName, $uploadId);
63 | }
64 | } else {
65 | $data->message = 'Unable to upload file';
66 | }
67 | }
68 |
69 | return $data;
70 | }
71 |
72 | /**
73 | * Does a repost check on the image and/or creates a database entry and saves it
74 | */
75 | private static function _handleImageUpload($imageUrl, $uploadId = null) {
76 | $retVal = new stdClass;
77 |
78 | $force = Lib\Url::GetBool('force');
79 | $retVal->uploadId = $uploadId ?: $imageUrl;
80 | $image = Api\Image::createFromUrl($imageUrl);
81 | if (null !== $image) {
82 |
83 | // Do a quick repost check
84 | if (!$force) {
85 | $reposts = Images::getByImage([ 'image' => $image, 'imageUri' => $imageUrl, 'count' => self::REPOST_CHECK_LIMIT ]);
86 | $retVal->similar = $reposts;
87 | if ($reposts && $reposts->identical) {
88 | $retVal->identical = $reposts->identical;
89 | }
90 | }
91 |
92 | if (!isset($retVal->identical) || $force) {
93 | // Sync to the database and save
94 | if ($image->sync()) {
95 | $retVal->imageId = $image->id;
96 | $retVal->imageUrl = $image->getFilename(true);
97 | $retVal->thumb = Thumb::createThumbFilename($retVal->imageUrl);
98 | } else {
99 | $retVal->error = true;
100 | $retVal->message = 'Error syncing image to database';
101 | }
102 | } else {
103 | $retVal->thumb = Thumb::createThumbFilename($imageUrl);
104 | }
105 |
106 | } else {
107 | $retVal->error = true;
108 | $retVal->message = 'Error reading image';
109 | }
110 |
111 | return $retVal;
112 | }
113 |
114 | private static function _checkUploadStatus() {
115 | $retVal = Lib\Http::getActiveDownloads();
116 | return count($retVal) > 0 ? $retVal : null;
117 | }
118 |
119 | public static function getUploadedFilePath($uploadId) {
120 | return sys_get_temp_dir() . '/image_' . $_SERVER['REMOTE_ADDR'] . '_' . $uploadId;
121 | }
122 |
123 | }
124 |
125 | }
--------------------------------------------------------------------------------
/cron/clean.php:
--------------------------------------------------------------------------------
1 | ";
66 |
67 | Api\DxApi::clean();
68 |
--------------------------------------------------------------------------------
/lib/aal.php:
--------------------------------------------------------------------------------
1 | $key));
98 | return $obj->body;
99 | }
100 |
101 | /**
102 | * Wrapper to set a KVP
103 | * @param string $key Name of option to set
104 | * @param mixed $value Value to store
105 | * @return Returns the success of the set
106 | */
107 | public static function setOption($key, $value) {
108 | return self::post('kvp', 'set', array('key'=>$key), $value);
109 | }
110 |
111 | /**
112 | * Makes an internal API call
113 | */
114 | private static function _internal($module, $method, $params, $cache) {
115 | global $_apiHits;
116 | $cacheKey = md5($module . '-' . $method . '-' . serialize($params));
117 | $retVal = Lib\Cache::Get($cacheKey);
118 | if ($retVal === false || $cache == 0) {
119 | $_apiHits++;
120 | $retVal = Api\DxApi::handleRequest($module, $method, $params);
121 | Lib\Cache::Set($cacheKey, $retVal);
122 | }
123 | return $retVal;
124 | }
125 |
126 | /**
127 | * Makes an API call to another instance of DxApi via REST
128 | */
129 | private static function _external($module, $method, $params, $cache, $apiUri) {
130 |
131 | global $_apiHits;
132 |
133 | $qs = '/index.php?type=json&method=' . $module . '.' . $method;
134 |
135 | // Build the query string
136 | if (count($params) > 0) {
137 | foreach ($params as $key=>$val) {
138 | $qs .= "&$key=".urlencode($val);
139 | }
140 | }
141 |
142 | // Check to see if there is a cached version of this
143 | $cacheKey = md5($apiUri.$qs);
144 | $retVal = Cache::Get($cacheKey);
145 | if ($retVal === false || $cache == 0) {
146 | $_apiHits++;
147 | $file = file_get_contents($apiUri . $qs);
148 | $retVal = json_decode($file);
149 | // Only cache on success
150 | if ($retVal->status->ret_code == 0) {
151 | Cache::Set($cacheKey, $retVal, $cache);
152 | }
153 | }
154 |
155 | // Return the request
156 | return $retVal;
157 |
158 | }
159 |
160 | }
161 |
162 | }
--------------------------------------------------------------------------------
/lib/cache.php:
--------------------------------------------------------------------------------
1 | setDisabled(true);
32 | } else {
33 | $this->_memcache = new Memcached();
34 | if (!$this->_memcache->addServer($host, $port)) {
35 | $this->_memcache = null;
36 | }
37 |
38 | // Since this is self-running, we don't yet have the benefit of the URL
39 | // parser having run. Pluck this out of the query string.
40 | if (isset($_SERVER['REQUEST_URI'])) {
41 | $requestUri = explode('?', $_SERVER['REQUEST_URI']);
42 | $this->setDisabled(strpos(end($requestUri), 'flushCache') !== false);
43 | } else {
44 | // In a CLI environment, don't bother with cache
45 | $this->setDisabled(true);
46 | }
47 | }
48 | }
49 |
50 | public function set($key, $val, $expiration = 600) {
51 | $retVal = false;
52 | if (null !== $this->_memcache && is_string($key)) {
53 | // Hash the key to obfuscate and to avoid the cache-key size limit
54 | $key = $this->_formatCacheKey($key);
55 | $retVal = $this->_memcache->set($key, $val, time() + $expiration);
56 | if (!$retVal) {
57 | var_dump($this->_memcache->getResultCode());
58 | }
59 | }
60 | return $retVal;
61 | }
62 |
63 | public function get($key, $forceCacheGet = false) {
64 | $retVal = false;
65 | $fetchFromCache = null != $this->_memcache && is_string($key) && ($forceCacheGet || !$this->_disabled);
66 | if ($fetchFromCache) {
67 | $formattedKey = $this->_formatCacheKey($key);
68 | $retVal = $this->_memcache->get($formattedKey);
69 | }
70 | return $retVal;
71 | }
72 |
73 | public function setDisabled($disabled) {
74 | $this->_disabled = $disabled;
75 | }
76 |
77 | /**
78 | * Creates a cache key using selected values from an array of values (usually _GET)
79 | */
80 | public static function createCacheKey($prefix, $params, $values) {
81 | $retVal = [ $prefix ];
82 | foreach ($params as $param) {
83 | $value = Url::Get($param, 'null', $values);
84 | if (is_array($value)) {
85 | $value = implode(',', $value);
86 | }
87 | $retVal[] = $value;
88 | }
89 | return implode('_', $retVal);
90 | }
91 |
92 | /**
93 | * Attempts to get data from cache. On miss, executes the callback function, caches that value, and returns it
94 | */
95 | public function fetch($method, $cacheKey, $duration = CACHE_MEDIUM) {
96 | $retVal = $this->get($cacheKey);
97 | if ($retVal === false && is_callable($method)) {
98 | $retVal = $method();
99 | $this->set($cacheKey, $retVal, $duration);
100 | }
101 | return $retVal;
102 | }
103 |
104 | private function _formatCacheKey($key) {
105 | return CACHE_PREFIX . ':' . md5($key);
106 | }
107 |
108 | }
109 |
110 | }
111 |
--------------------------------------------------------------------------------
/lib/db.php:
--------------------------------------------------------------------------------
1 | setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
39 | $retVal = true;
40 | } catch (PDOException $e) {
41 | self::$lastError = $e->Message();
42 | }
43 |
44 | return $retVal;
45 | }
46 |
47 | /**
48 | * Gets the current connection to the database or opens a new one
49 | * if the connection hasn't already been opened
50 | */
51 | public static function getConnection()
52 | {
53 | // Open the connection if it doesn't already exist
54 | if (!self::$_conn) {
55 | self::Connect('mysql:dbname=' . DB_NAME . ';host=' . DB_HOST, DB_USER, DB_PASS);
56 | }
57 |
58 | return self::$_conn;
59 |
60 | }
61 |
62 | /**
63 | * Executes a query
64 | */
65 | public static function Query($sql, $params = null)
66 | {
67 |
68 | $conn = self::getConnection();
69 |
70 | $retVal = null;
71 |
72 | self::$callCount++;
73 |
74 | try {
75 | $comm = $conn->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
76 | $success = $comm->execute($params);
77 |
78 | switch (strtolower(current(explode(' ', $sql)))) {
79 | case 'call':
80 | case 'select':
81 | $retVal = new stdClass();
82 | $retVal->count = $comm->rowCount();
83 | $retVal->comm = $comm;
84 | break;
85 | case 'insert':
86 | $retVal = $conn->lastInsertId();
87 |
88 | // Rows without a primary key won't return an ID, so fallback to the query success
89 | if (!$retVal) {
90 | $retVal = $success;
91 | }
92 | break;
93 | case 'update':
94 | case 'delete':
95 | // In case row count is 0, return the success of the query
96 | $retVal = $comm->rowCount() ?: $success;
97 | break;
98 | }
99 |
100 | self::$lastError = $conn->errorInfo();
101 |
102 | } catch (Exception $e) {
103 | self::$lastError = $e->Message();
104 | throw $e;
105 | }
106 |
107 | return $retVal;
108 | }
109 |
110 | /**
111 | * Fetches the next row in a record set
112 | */
113 | public static function Fetch($rs)
114 | {
115 | $retVal = null;
116 |
117 | if (is_object($rs) && null != $rs->comm) {
118 | $retVal = $rs->comm->fetchObject();
119 | }
120 |
121 | return $retVal;
122 | }
123 |
124 | }
125 | }
--------------------------------------------------------------------------------
/lib/display.php:
--------------------------------------------------------------------------------
1 | new \Handlebars\Loader\FilesystemLoader(__DIR__ . '/../views/', [ 'extension' => 'hbs' ]),
20 | 'partials_loader' => new \Handlebars\Loader\FilesystemLoader(__DIR__ . '/../views/partials/', [ 'extension' => 'hbs' ])
21 | ]);
22 | self::addKey(KEY_CLIENT_DATA, new stdClass);
23 | self::_addStandardHelpers();
24 | }
25 |
26 | /**
27 | * Renders the page
28 | **/
29 | public static function render() {
30 | echo self::$_hbEngine->render('layouts/' . self::$_layout . '.hbs', self::$_tplData);
31 | }
32 |
33 | // Displays an error message and halts rendering
34 | public static function showError($code, $message) {
35 | // NOOP until I can figure out what to do with this
36 | }
37 |
38 | public static function setTheme($name) {
39 | self::$_theme = $name;
40 | }
41 |
42 | public static function setLayout($name) {
43 | self::$_layout = $name;
44 | }
45 |
46 | /**
47 | * Renders data against a template and adds it to the output object
48 | * @param string $key Key found in the layout template
49 | * @param string $template Template name to render
50 | * @param string $data Data to render against template
51 | */
52 | public static function renderAndAddKey($key, $template, $data) {
53 | self::addKey($key, self::$_hbEngine->render($template, $data));
54 | }
55 |
56 | /**
57 | * Adds a key/value to the output data
58 | * @param string $key Key found in the layout template
59 | * @param string $template Data to associate to the key
60 | */
61 | public static function addKey($key, $value) {
62 | self::$_tplData[$key] = $value;
63 | }
64 |
65 | /**
66 | * Adds data to the outgoing client side data blob
67 | */
68 | public static function addClientData($key, $obj) {
69 | self::$_tplData[KEY_CLIENT_DATA]->$key = $obj;
70 | }
71 |
72 | /**
73 | * Adds a helper to the Handlebars engine
74 | */
75 | public static function addHelper($name, $function) {
76 | self::$_hbEngine->addHelper($name, $function);
77 | }
78 |
79 | /**
80 | * Adds a set of standard utility helpers to the render engine
81 | */
82 | private static function _addStandardHelpers() {
83 | self::addHelper('relativeTime', function($template, $context, $args, $source) {
84 | return Util::relativeTime($context->get($args));
85 | });
86 |
87 | self::addHelper('formatStandardDate', function($template, $context, $args, $source) {
88 | return Util::formatStandardDate($context->get($args));
89 | });
90 |
91 | // Idea lifted right out of dust.js
92 | self::addHelper('sep', function($template, $context, $args, $source) {
93 | if (!$context->get('@last')) {
94 | return $source;
95 | }
96 | });
97 |
98 | self::addHelper('jsonBlob', function($template, $context, $args, $source) {
99 | return json_encode($context->get($args));
100 | });
101 | }
102 |
103 | }
104 |
105 | Display::init();
106 |
107 | }
--------------------------------------------------------------------------------
/lib/events.php:
--------------------------------------------------------------------------------
1 | eventType = $eventType;
35 | $out->data = $data;
36 | $out = json_encode($out);
37 |
38 | // Pad out the request to a minimum of 2K for Chrome
39 | if (strlen($out) < 4096) {
40 | $out = str_pad($out, 4096, ' ');
41 | }
42 | echo $out;
43 | flush();
44 | }
45 |
46 | /**
47 | * Registers a callback for an event type
48 | */
49 | public static function addEventListener($eventType, $callback) {
50 | if (is_callable($callback)) {
51 | if (!isset(self::$events[$eventType])) {
52 | self::$events[$eventType] = [];
53 | }
54 | self::$events[$eventType][] = $callback;
55 | }
56 | }
57 |
58 | /**
59 | * Fires an events and triggers any register event listeners
60 | */
61 | public static function fire($eventType, $data = null) {
62 | if (isset(self::$events[$eventType])) {
63 | foreach (self::$events[$eventType] as $callback) {
64 | $callback($data);
65 | }
66 | }
67 | }
68 |
69 | }
70 |
71 | }
--------------------------------------------------------------------------------
/lib/ga.php:
--------------------------------------------------------------------------------
1 | toString();
53 | }
54 |
55 | $retVal = [
56 | 'v' => 1,
57 | 'tid' => GA_ID,
58 | 'cid' => self::$clientId,
59 | 't' => 'event',
60 | 'ec' => $category,
61 | 'ea' => $action
62 | ];
63 |
64 | if ($label) {
65 | $retVal['el'] = $label;
66 | }
67 |
68 | if ($value && is_numeric($value)) {
69 | $retVal['ev'] = $value;
70 | }
71 |
72 | return $retVal;
73 | }
74 |
75 | /**
76 | * Sends an array of event objects to GA via the selected endpoint
77 | */
78 | private static function _sendEvents($method, array $events) {
79 | $c = curl_init('https://www.google-analytics.com/collect');
80 |
81 | $events = array_map(function($event) {
82 | $values = [];
83 | foreach ($event as $key => $value) {
84 | $values[] = urlencode($key) . '=' . urlencode($value);
85 | }
86 | return implode('&', $values);
87 | }, $events);
88 |
89 | $events = implode("\r\n", $events) . "\r\n";
90 |
91 | curl_setopt($c, CURLOPT_RETURNTRANSFER, true);
92 | curl_setopt($c, CURLOPT_POST, strlen($events));
93 | curl_setopt($c, CURLOPT_POSTFIELDS, $events);
94 |
95 | $retVal = curl_exec($c);
96 | curl_close($c);
97 |
98 | return $retVal;
99 | }
100 |
101 | }
102 | }
--------------------------------------------------------------------------------
/lib/http.php:
--------------------------------------------------------------------------------
1 | _url = $url;
23 | $c = curl_init($url);
24 | curl_setopt($c, CURLOPT_USERAGENT, HTTP_UA);
25 | curl_setopt($c, CURLOPT_FOLLOWLOCATION, true);
26 |
27 | // Not the most ethical thing, but fake the referer as being from the host root
28 | $bits = parse_url($url);
29 | curl_setopt($c, CURLOPT_REFERER, 'http://' . $bits['host']);
30 |
31 | curl_setopt($c, CURLOPT_PROGRESSFUNCTION, [ $this, '_progress' ]);
32 | curl_setopt($c, CURLOPT_NOPROGRESS, false);
33 |
34 | if (is_array($headers)) {
35 | curl_setopt($c, CURLOPT_HTTPHEADER, $headers);
36 | }
37 |
38 | curl_setopt($c, CURLOPT_RETURNTRANSFER, true);
39 | curl_setopt($c, CURLINFO_HEADER_OUT, true);
40 | curl_setopt($c, CURLOPT_TIMEOUT, 60);
41 | curl_setopt($c, CURLOPT_ENCODING, 'gzip');
42 | $retVal = curl_exec($c);
43 |
44 | if (null == $retVal) {
45 | self::$_lastError = curl_error($c);
46 | }
47 |
48 | curl_close($c);
49 | $this->_requestComplete($url);
50 |
51 | return $retVal;
52 | }
53 |
54 | /**
55 | * A drop in replacement for file_get_contents with some business logic attached
56 | * @param string $url Url to retrieve
57 | * @return string Data received
58 | */
59 | private static function curl_get_contents($url, $headers = null) {
60 | $request = new Http();
61 | return $request->_get($url, $headers);
62 | }
63 |
64 | private static function _getCacheKey() {
65 | $client = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'local';
66 | return 'curl_progress_' . $client;
67 | }
68 |
69 | /**
70 | * Returns the cached list of active downloads
71 | */
72 | public static function getActiveDownloads() {
73 | $cacheKey = self::_getCacheKey();
74 | $urls = Cache::getInstance()->get($cacheKey);
75 | return $urls ?: [];
76 | }
77 |
78 | private static function _setActiveDownloads($urls) {
79 | Cache::getInstance()->set(self::_getCacheKey(), $urls, CACHE_SHORT);
80 | }
81 |
82 | /**
83 | * A callback function to monitor cURL's download progress for a client
84 | */
85 | private function _progress($c, $totalBytesDown, $bytesDownloaded, $totalBytesUp, $bytesUploaded) {
86 | $urls = self::getActiveDownloads();
87 | $urls[$this->_url] = $totalBytesDown > 0 ? $bytesDownloaded / $totalBytesDown : 0;
88 | self::_setActiveDownloads($urls);
89 | }
90 |
91 | /**
92 | * Cleanup operations for when a cURL request completes
93 | */
94 | private function _requestComplete($url) {
95 | $urls = self::getActiveDownloads();
96 | if (isset($urls[$url])) {
97 | unset($urls[$url]);
98 | self::_setActiveDownloads($urls);
99 | }
100 | }
101 |
102 | }
103 |
104 | }
--------------------------------------------------------------------------------
/lib/redditoauth.php:
--------------------------------------------------------------------------------
1 | clientId = $clientId;
20 | $this->clientSecret = $clientSecret;
21 | $this->clientUserAgent = $userAgent;
22 | $this->handlerUrl = $handlerUrl;
23 | }
24 |
25 | /**
26 | * Makes a call to get the token or, if a token has been retrieved, returns the last result
27 | */
28 | public function getToken($code = '') {
29 |
30 | // If we have a token set and a new code isn't being passed in,
31 | // use what's already available
32 | $retVal = $this->token && !$code ? $this->token : false;
33 |
34 | if (!$retVal && $code) {
35 |
36 | $response = $this->_post('access_token', [
37 | 'grant_type' => 'authorization_code',
38 | 'code' => $code,
39 | 'redirect_uri' => $this->handlerUrl
40 | ], false);
41 |
42 | $this->_updateToken($response);
43 | $retVal = $this->token;
44 |
45 | }
46 |
47 | return $retVal;
48 | }
49 |
50 | public function setToken($token) {
51 | $this->token = $token;
52 | }
53 |
54 | public function getRefreshToken() {
55 | return $this->refreshToken;
56 | }
57 |
58 | public function setRefreshToken($refreshToken) {
59 | $this->refreshToken = $refreshToken;
60 | }
61 |
62 | public function getExpiration() {
63 | return $this->expiration;
64 | }
65 |
66 | public function setExpiration($expiration) {
67 | $this->expiration = $expiration;
68 | }
69 |
70 | public function getLoginUrl($duration, array $scope, $state = null) {
71 | $params = [
72 | 'response_type' => 'code',
73 | 'client_id' => $this->clientId,
74 | 'scope' => implode(',', $scope),
75 | 'redirect_uri' => $this->handlerUrl,
76 | 'state' => $state ? $state : md5(rand()),
77 | 'duration' => $duration
78 | ];
79 |
80 | return self::REDDIT_API_URL . '/authorize?' . $this->_urlEncodeKVPs($params);
81 | }
82 |
83 | /**
84 | * Makes an OAuthenticated API call to reddit
85 | */
86 | public function call($endpoint, $params = null) {
87 | $retVal = false;
88 |
89 | if ($this->token) {
90 | if (!$params) {
91 | $retVal = $this->_get($endpoint);
92 | } else {
93 | $retVal = $this->_post($endpoint, $params);
94 | }
95 | }
96 |
97 | return $retVal;
98 | }
99 |
100 | private function _updateToken($response) {
101 | if ($response && isset($response->access_token)) {
102 | $this->token = $response->access_token;
103 | if (isset($response->refresh_token)) {
104 | $this->refreshToken = $response->refresh_token;
105 | }
106 |
107 | if (isset($response->expires_in)) {
108 | $this->expiration = time() + $response->expires_in;
109 | }
110 | $retVal = $this->token;
111 | } else {
112 | $this->token = null;
113 | }
114 | }
115 |
116 | /**
117 | * Verifies that the current token is still fresh. If not,
118 | * refreshs if possible
119 | */
120 | private function _verifyTokenFresh() {
121 | $retVal = time() < $this->expiration;
122 |
123 | if (!$retVal && $this->refreshToken) {
124 | $response = $this->_post('access_token', [
125 | 'grant_type' => 'refresh_token',
126 | 'refresh_token' => $this->refreshToken
127 | ], false);
128 |
129 | $this->_updateToken($response);
130 | $retVal = !!$this->token;
131 | }
132 |
133 | return $retVal;
134 | }
135 |
136 | private function _createCurl($endpoint, $isOauth = true) {
137 | $apiUrl = $isOauth ? self::REDDIT_OAUTH_URL : self::REDDIT_API_URL;
138 |
139 | $retVal = curl_init($apiUrl . '/' . $endpoint);
140 | curl_setopt($retVal, CURLOPT_RETURNTRANSFER, true);
141 | curl_setopt($retVal, CURLOPT_USERAGENT, $this->clientUserAgent);
142 |
143 | // For authenticated requests
144 | if ($isOauth && $this->_verifyTokenFresh()) {
145 | curl_setopt($retVal, CURLOPT_HTTPHEADER, [ 'Authorization: bearer ' . $this->token ]);
146 | // For authentication requests
147 | } else {
148 | curl_setopt($retVal, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
149 | curl_setopt($retVal, CURLOPT_USERPWD, $this->clientId . ':' . $this->clientSecret);
150 | }
151 |
152 | return $retVal;
153 | }
154 |
155 | private function _get($endpoint) {
156 | $c = $this->_createCurl($endpoint);
157 | $response = curl_exec($c);
158 | return $response ? json_decode($response) : false;
159 | }
160 |
161 | /**
162 | * Makes an OAuthenticated POST request
163 | */
164 | private function _post($endpoint, array $params, $isOauth = true) {
165 | $c = $this->_createCurl($endpoint, $isOauth);
166 | curl_setopt($c, CURLOPT_POST, true);
167 | curl_setopt($c, CURLOPT_POSTFIELDS, $params);
168 | $response = curl_exec($c);
169 | return $response ? json_decode($response) : false;
170 | }
171 |
172 | private function _urlEncodeKVPs($params) {
173 | $retVal = [];
174 | foreach ($params as $key => $value) {
175 | $retVal[] = urlencode($key) . '=' . urlencode($value);
176 | }
177 | return implode('&', $retVal);
178 | }
179 | }
180 |
181 | }
--------------------------------------------------------------------------------
/lib/serializexml.php:
--------------------------------------------------------------------------------
1 | ' . self::_serializeItem($object, $root);
10 |
11 | }
12 |
13 | private function _serializeItem($item, $root, $attributes = '') {
14 |
15 | $retVal = '<' . $root . $attributes . '>';
16 |
17 | if (null === $item) {
18 | $retVal .= 'null';
19 | } elseif (is_object($item) || is_array($item)) {
20 |
21 | foreach ($item as $key=>$val) {
22 | $elName = is_numeric($key) ? $root . '_item' : $key;
23 | $elAttr = is_numeric($key) ? ' index="' . $key . '"' : '';
24 | $retVal .= self::_serializeItem($val, $elName, $elAttr);
25 | }
26 |
27 | } elseif (is_bool($item)) {
28 | $retVal .= false == $item ? 'false' : 'true';
29 | } elseif (is_string($item)) {
30 | $value = htmlspecialchars($item, ENT_NOQUOTES | 8, 'UTF-8', false);
31 | $value = str_replace(array('<', '>'), array('<', '>'), $value);
32 | $retVal .= '';
33 | } elseif (is_numeric($item)) {
34 | $retVal .= $item;
35 | } else {
36 | // $retVal .= $item;
37 | }
38 |
39 | $retVal .= '' . $root . '>';
40 | return $retVal;
41 |
42 | }
43 |
44 | }
45 |
46 | }
--------------------------------------------------------------------------------
/lib/session.php:
--------------------------------------------------------------------------------
1 | get(RBSESS . '_' . self::$_id, true);
22 | }
23 |
24 | public static function get($key) {
25 | $retVal = null;
26 | if (self::$_sess instanceof stdClass && isset(self::$_sess->$key)) {
27 | $retVal = self::$_sess->$key;
28 | }
29 | return $retVal;
30 | }
31 |
32 | public static function set($key, $value) {
33 | if (!self::$_sess instanceof stdClass) {
34 | self::$_sess = new stdClass;
35 | }
36 | self::$_sess->$key = $value;
37 | Cache::getInstance()->set(RBSESS . '_' . self::$_id, self::$_sess, SESSION_EXPIRE);
38 | }
39 |
40 | }
41 |
42 | }
--------------------------------------------------------------------------------
/lib/testbucket.php:
--------------------------------------------------------------------------------
1 | fetch(function() {
23 | return json_decode(@file_get_contents('buckets.json'));
24 | }, TEST_BUCKET_CACHE_KEY);
25 | self::$_initialized = true;
26 |
27 | // Add template helpers
28 | Display::addHelper('inTestBucket', function($template, $context, $args, $source) {
29 |
30 | if (preg_match_all('/([\w]+)=\"([^\"]+)\"/i', $args, $matches)) {
31 | $args = new stdClass;
32 | for ($i = 0, $count = count($matches[0]); $i < $count; $i++) {
33 | $key = $matches[1][$i];
34 | $args->$key = str_replace('"', '', $matches[2][$i]);
35 | }
36 |
37 | if (isset($args->key) && isset($args->value)) {
38 | if (self::get($args->key) == $args->value) {
39 | return $template->render($context);
40 | }
41 | } else {
42 | throw new Exception('inTestBucket requires "key" and "test" parameters');
43 | }
44 |
45 | }
46 |
47 | });
48 |
49 | }
50 |
51 | if ($seed || !self::$_seed) {
52 | $defaultSeed = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : time();
53 | self::$_seed = $seed ?: (int) str_replace('.', '', $defaultSeed);
54 | }
55 |
56 | }
57 |
58 | /**
59 | * Gets a test bucket value for the given seed
60 | */
61 | public static function get($key, $seed = null) {
62 |
63 | self::initialize();
64 |
65 | $seed = $seed ?: self::$_seed;
66 | $cacheKey = 'test_' . $key . '_' . $seed;
67 |
68 | // Check for a query string override
69 | $override = Url::Get('tb.' . $key, null);
70 | if ($override) {
71 | return $override;
72 | }
73 |
74 | return Cache::getInstance()->fetch(function() use ($key, $seed) {
75 | $retVal = DEFAULT_TEST_VALUE;
76 | $found = false;
77 |
78 | if (isset(self::$_tests->$key)) {
79 | $test = self::$_tests->$key;
80 | if (isset($test->whiteLists)) {
81 | foreach ($test->whiteLists as $whiteList) {
82 | if (in_array($seed, $whiteList->ids)) {
83 | $retVal = $whiteList->value;
84 | $found = true;
85 | break;
86 | }
87 | }
88 | }
89 |
90 | if (!$found && isset($test->ramps)) {
91 | srand($seed);
92 | $rand = rand() % 100;
93 | $percentTotal = 0;
94 | foreach ($test->ramps as $ramp) {
95 | $percentTotal += $ramp->percent;
96 | if ($rand <= $percentTotal) {
97 | $retVal = $ramp->value;
98 | break;
99 | }
100 | }
101 | }
102 | }
103 |
104 | return $retVal;
105 | }, $cacheKey);
106 | }
107 |
108 | }
109 |
110 | }
--------------------------------------------------------------------------------
/lib/url.php:
--------------------------------------------------------------------------------
1 | fetch(function() use ($configFile) {
67 | return json_decode(@file_get_contents($configFile));
68 | }, 'url_rewrites');
69 |
70 | if ($rewrites) {
71 |
72 | $qs = null;
73 | foreach($rewrites as $rewrite) {
74 | $expr = '@' . $rewrite->rule . '@is';
75 |
76 | if (preg_match($expr, $uri)) {
77 | $qs = preg_replace($expr, $rewrite->replace, $uri);
78 | }
79 |
80 | if ($qs && isset($rewrite->redirect) && $rewrite->redirect === true) {
81 | header('Location: ' . $qs);
82 | exit();
83 | } else if ($qs) {
84 |
85 | $params = explode('&', $qs);
86 | foreach ($params as $param) {
87 | $temp = explode('=', $param);
88 | self::$params[$temp[0]] = $temp[1];
89 | $_GET[$temp[0]] = $temp[1];
90 | }
91 |
92 | $retVal = true;
93 | break;
94 |
95 | }
96 |
97 | }
98 |
99 | } else {
100 | throw new Exception('URL_REWRITE: Congig file empty or malformed');
101 | }
102 |
103 | }
104 |
105 | return $retVal;
106 |
107 | }
108 |
109 | /**
110 | * Gets an item from POST and cleans magic quotes if necessary
111 | */
112 | public static function Post($param, $isInt = false) {
113 | $retVal = null;
114 | if (isset($_POST[$param])) {
115 | $retVal = get_magic_quotes_gpc() > 0 ? stripslashes($_POST[$param]) : $_POST[$param];
116 | }
117 |
118 | if ($isInt && $retVal) {
119 | if (!is_numeric($retVal)) {
120 | $retVal = null;
121 | }
122 | }
123 |
124 | if ($retVal) {
125 | if (is_array($retVal)) {
126 | foreach ($retVal as &$item) {
127 | $item = trim($item);
128 | }
129 | } else {
130 | $retVal = trim($retVal);
131 | }
132 | }
133 |
134 | return $retVal;
135 | }
136 |
137 | /**
138 | * Gets the requested item off the query string if it exists
139 | */
140 | public static function Get($param, $default = false, $source = null) {
141 | $source = $source ?: $_GET;
142 | return isset($source[$param]) ? $source[$param] : $default;
143 | }
144 |
145 | /**
146 | * Gets an int value off the query string. If the value exists but is NaN, returns null
147 | */
148 | public static function GetInt($param, $default = 0, $source = null) {
149 | $source = $source ?: $_GET;
150 | return isset($source[$param]) && is_numeric($source[$param]) ? intVal($source[$param]) : $default;
151 | }
152 |
153 | /**
154 | * Gets a bool value off the query string
155 | */
156 | public static function GetBool($param, $source = null) {
157 | $source = $source ?: $_GET;
158 | return isset($source[$param]) && ($source[$param] === true || strtolower($source[$param]) === 'true') ? true : false;
159 | }
160 |
161 |
162 |
163 | }
164 |
165 | }
166 |
--------------------------------------------------------------------------------
/lib/util.php:
--------------------------------------------------------------------------------
1 | = YEAR_SECONDS) {
23 | $amount = floor($delta / YEAR_SECONDS);
24 | $unit = 'year';
25 | } else if ($delta >= MONTH_SECONDS) {
26 | $amount = floor($delta / MONTH_SECONDS);
27 | $unit = 'month';
28 | } else if ($delta >= DAY_SECONDS) {
29 | $amount = floor($delta / DAY_SECONDS);
30 | $unit = 'day';
31 | } else if ($delta >= HOUR_SECONDS) {
32 | $amount = floor($delta / HOUR_SECONDS);
33 | $unit = 'hour';
34 | } else if ($delta >= MINUTE_SECONDS) {
35 | $amount = floor($delta / MINUTE_SECONDS);
36 | $unit = 'minute';
37 | }
38 |
39 | return $amount . ' ' . $unit . ((int) $amount !== 1 ? 's' : '');
40 |
41 | }
42 |
43 | public static function formatStandardDate($timestamp) {
44 | return date('F j, Y', $timestamp);
45 | }
46 |
47 | }
48 |
49 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "RedditBooru",
3 | "version": "1.0.0",
4 | "description": "An repository for anime image based subreddits",
5 | "repository": "https://github.com/dxprog/reddit-booru",
6 | "author": "Matt (dxprog) Hackmann",
7 | "license": "GPLv3",
8 | "scripts": {
9 | "build": "webpack --config webpack.prod.js",
10 | "start": "webpack --watch"
11 | },
12 | "devDependencies": {
13 | "backbone": "^1.2.3",
14 | "css-loader": "^2.1.1",
15 | "handlebars": "^4.0.3",
16 | "handlebars-loader": "^1.7.1",
17 | "jquery": "^3.4.1",
18 | "mini-css-extract-plugin": "^0.6.0",
19 | "node-sass": "^4.12.0",
20 | "sass": "1.19.0",
21 | "sass-loader": "^7.1.0",
22 | "style-loader": "^0.23.1",
23 | "ts-loader": "^5.4.5",
24 | "typescript": "^3.4.5",
25 | "underscore": "^1.9.1",
26 | "webpack": "^4.30.0",
27 | "webpack-cli": "^3.3.1"
28 | },
29 | "dependencies": {
30 | "acorn": "^6.1.1",
31 | "request": "^2.72.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/services/saucenao-request.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const request = require('request');
4 |
5 | const SAUCENAO_KEY = process.env.SVC_SAUCENAO_KEY;
6 | const SAUCENAO_DB = 5; // Currently, only search for pixiv sources
7 | const SAUCENAO_URL = `https://saucenao.com/search.php?db=${SAUCENAO_DB}&output_type=2&testmode=1&numres=16&api_key=${SAUCENAO_KEY}&url=`;
8 | const TIMEOUT = 60000;
9 |
10 | module.exports = class SauceNaoRequest {
11 | constructor(req, res) {
12 | this.req = req;
13 | this.res = res;
14 | this.url = req.url.replace('/', '');
15 | this.done = false;
16 | this.timerHandle = null;
17 | this.events = {};
18 | this.startTime = false;
19 | }
20 |
21 | on(event, cb) {
22 | if (!this.events.hasOwnProperty(event)) {
23 | this.events[event] = [];
24 | }
25 | this.events[event].push(cb);
26 | }
27 |
28 | fire(event, data) {
29 | if (event in this.events) {
30 | this.events[event].forEach((cb) => {
31 | cb(data);
32 | });
33 | }
34 | }
35 |
36 | start() {
37 | this.timerHandle = setTimeout(this.timedOut.bind(this), TIMEOUT);
38 | this.startTime = Date.now();
39 | console.log(`Request started for ${decodeURIComponent(this.url)}`);
40 | request(`${SAUCENAO_URL}${this.url}`, (err, res, body) => {
41 | if (!err && !this.done) {
42 | console.log(`Request finished successfully for ${decodeURIComponent(this.url)}`);
43 | this.res.write(body);
44 | } else {
45 | console.log(`Request failed for ${decodeURIComponent(this.url)}`);
46 | }
47 | if (!this.done) {
48 | this.res.end();
49 | this.done = true;
50 | }
51 | clearTimeout(this.timerHandle);
52 | this.fire('complete');
53 | });
54 | }
55 |
56 | timedOut() {
57 | console.log(`Request timed out for ${decodeURIComponent(this.url)}`);
58 | this.res.end();
59 | this.done = true;
60 | this.fire('timeout');
61 | }
62 | };
--------------------------------------------------------------------------------
/services/saucenao-service.js:
--------------------------------------------------------------------------------
1 | const http = require('http');
2 |
3 | const SauceNaoRequest = require('./saucenao-request');
4 |
5 | const SERVER_PORT = process.env.SVC_SAUCENAO_PORT;
6 |
7 | var requests = [];
8 |
9 | // Technically, SauceNAO's free limit is 20 requests per 30
10 | // seconds, but I want a little bit of leeway.
11 | var MAX_REQUESTS = 10;
12 | var TIME_SPAN = 30000;
13 |
14 | function manageQueue() {
15 | // Get all uncompleted requests and then filter out the ones
16 | // that haven't yet started.
17 | const uncompletedRequests = requests.filter((request) => !request.done);
18 | var activeRequests = 0;
19 | uncompletedRequests.forEach((request) => {
20 | activeRequests += !request.startTime ? 0 : 1;
21 | });
22 | const waitingRequests = uncompletedRequests.filter((request) => !request.startTime);
23 |
24 | if (waitingRequests.length > 0 && activeRequests < MAX_REQUESTS) {
25 | while (activeRequests < MAX_REQUESTS && !!waitingRequests.length) {
26 | const request = waitingRequests.shift();
27 | request.start();
28 | activeRequests++;
29 | }
30 | }
31 |
32 | // The old requests array gets wiped away with our new active only requests
33 | requests = uncompletedRequests;
34 | }
35 |
36 | var server = http.createServer((req, res) => {
37 | const request = new SauceNaoRequest(req, res);
38 | request.on('complete', manageQueue);
39 | request.on('timeout', manageQueue);
40 | requests.push(request);
41 | manageQueue();
42 | });
43 |
44 | server.listen(SERVER_PORT);
45 | console.log(`Listening on port ${SERVER_PORT}`);
--------------------------------------------------------------------------------
/services/task-runner.js:
--------------------------------------------------------------------------------
1 | var _ = require('underscore'),
2 | child_process = require('child_process'),
3 | request = require('request'),
4 | cmd = 'php cron/cron.php',
5 | CRON_DELAY = 2 * 60 * 1000,
6 | HEALTH_DELAY = 60 * 1000,
7 | MAX_PROCESSES = 4,
8 | SOURCES_TO_CHECK = [],
9 |
10 | cronTimer = null,
11 | healthTimer = null,
12 | start = null,
13 | currentSource = 0,
14 | processes = [],
15 |
16 | Process = function(sourceId, completeCallback) {
17 | this.sourceId = sourceId;
18 | this.completeCallback = completeCallback;
19 | this.task = this.startTask();
20 | };
21 |
22 | Process.prototype.startTask = function() {
23 | var self = this,
24 | task = null;
25 |
26 | console.log('Spawning cron task for source ' + self.sourceId + '...');
27 | self._start = Date.now();
28 |
29 | // Spawn the PHP script
30 | task = child_process.spawn('php', [ 'cron/cron.php', '--source=' + self.sourceId ]);
31 | task.stdout.on('data', function(chunk) {
32 | // console.log(self.sourceId + ' - ' + chunk.toString());
33 | clearTimeout(self.healthTimer);
34 | self.healthTimer = setTimeout(_.bind(self.healthUpdate, self), HEALTH_DELAY);
35 | });
36 |
37 | // Clean up when the process is done
38 | task.on('exit', function() {
39 | clearTimeout(self.healthTimer);
40 | console.log('Task for source ' + self.sourceId + ' finished in ' + ((Date.now() - self._start) / 1000) + ' seconds.');
41 | self.task = null;
42 | self.completeCallback();
43 | });
44 |
45 | self.healthTimer = setTimeout(_.bind(self.healthUpdate, self), HEALTH_DELAY);
46 |
47 | return task;
48 | };
49 |
50 | // I expect the PHP script to reasonably spit out data roughly every 30s
51 | // If we get nothing, kill the task
52 | Process.prototype.healthUpdate = function() {
53 | if (null !== this.task) {
54 | console.log('Script for source ' + this.sourceId + ' has hung, killing');
55 | this.task.kill('SIGHUP');
56 | this.task = null;
57 | this.completeCallback();
58 | }
59 | };
60 |
61 | function taskComplete() {
62 |
63 | var i = 0;
64 |
65 | // Find this task in the processes and remove it
66 | for (; i < MAX_PROCESSES; i++) {
67 | if (processes[i] == this) {
68 | processes.splice(i, 1);
69 | break;
70 | }
71 | }
72 |
73 | // Reset the cron timer and run the next task
74 | clearTimeout(cronTimer);
75 | taskRunner();
76 |
77 | }
78 |
79 | function getActiveSources(callback) {
80 | request.get('https://redditbooru.com/sources/', function(err, res, body) {
81 | if (!err) {
82 | try {
83 | var sources = JSON.parse(body),
84 | i = 0,
85 | count = sources instanceof Array ? sources.length : 0;
86 | SOURCES_TO_CHECK = [];
87 | for (; i < count; i++) {
88 | SOURCES_TO_CHECK.push(parseInt(sources[i].value, 10));
89 | }
90 | callback();
91 | } catch (exc) {
92 | console.log('Invalid JSON sources data, working off of old data');
93 | callback();
94 | }
95 | } else {
96 | console.error('Error retrieving sources');
97 | clearTimeout(cronTimer);
98 | cronTimer = setTimeout(taskRunner, CRON_DELAY);
99 | }
100 | });
101 | }
102 |
103 | function taskRunner() {
104 |
105 | getActiveSources(function() {
106 |
107 | var i = currentSource,
108 | count = SOURCES_TO_CHECK.length;
109 |
110 | if (i === 0) {
111 | start = Date.now();
112 | }
113 |
114 | for (; i < count; i++) {
115 | if (processes.length < MAX_PROCESSES) {
116 | processes.push(new Process(SOURCES_TO_CHECK[i], taskComplete));
117 | } else {
118 | break;
119 | }
120 | }
121 |
122 | // Save our spot and wrap if we're at the end of the whole list
123 | currentSource = i;
124 | if (i >= count && processes.length === 0) {
125 | currentSource = 0;
126 | clearTimeout(cronTimer);
127 | cronTimer = setTimeout(taskRunner, CRON_DELAY);
128 | console.log('Finished all sources in ' + ((Date.now() - start) / 1000) + ' seconds');
129 | }
130 | });
131 |
132 | }
133 |
134 | taskRunner();
135 | cronTimer = setTimeout(taskRunner, CRON_DELAY);
--------------------------------------------------------------------------------
/sql/proc_GetLinkedPosts.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * Updates denormalized post data from source of truth tables
3 | */
4 | DROP PROCEDURE IF EXISTS `proc_GetLinkedPosts`;
5 |
6 | DELIMITER //
7 |
8 | CREATE PROCEDURE `proc_GetLinkedPosts`
9 | (IN postId INT)
10 | BEGIN
11 |
12 | SELECT
13 | DISTINCT `post_id`,
14 | `post_title`,
15 | `post_date`,
16 | `post_external_id`,
17 | `source_name`,
18 | `user_name`
19 | FROM
20 | `post_data`
21 | WHERE
22 | `post_id` IN (
23 | SELECT
24 | `post_id`
25 | FROM
26 | `posts`
27 | WHERE
28 | `post_link` LIKE (
29 | SELECT
30 | CONCAT(`post_link`, '%')
31 | FROM
32 | `posts`
33 | WHERE
34 | `post_id` = postId
35 | LIMIT 1
36 | )
37 | AND `post_id` != postId
38 | );
39 |
40 | END //
--------------------------------------------------------------------------------
/sql/proc_UpdateDenormalizedPostData.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * Updates denormalized post data from source of truth tables
3 | */
4 | DROP PROCEDURE IF EXISTS `proc_UpdateDenormalizedPostData`;
5 |
6 | DELIMITER //
7 |
8 | CREATE PROCEDURE `proc_UpdateDenormalizedPostData`
9 | (IN postId INT)
10 | BEGIN
11 |
12 | DECLARE isInPostData INT;
13 | SELECT
14 | COUNT(1) INTO isInPostData
15 | FROM
16 | `post_data`
17 | WHERE
18 | `post_id` = postId;
19 |
20 | IF isInPostData = 0 THEN
21 | /* If it doesn't exist, create the records */
22 | BEGIN
23 | INSERT INTO
24 | `post_data`
25 | (
26 | `post_id`,
27 | `post_title`,
28 | `post_keywords`,
29 | `post_nsfw`,
30 | `post_date`,
31 | `post_external_id`,
32 | `post_score`,
33 | `post_visible`,
34 | `image_id`,
35 | `image_width`,
36 | `image_height`,
37 | `image_type`,
38 | `image_caption`,
39 | `image_source`,
40 | `source_id`,
41 | `source_name`,
42 | `user_id`,
43 | `user_name`
44 | )
45 | SELECT
46 | p.`post_id`,
47 | p.`post_title`,
48 | p.`post_keywords`,
49 | p.`post_nsfw`,
50 | p.`post_date`,
51 | p.`post_external_id`,
52 | p.`post_score`,
53 | p.`post_visible`,
54 | i.`image_id`,
55 | i.`image_width`,
56 | i.`image_height`,
57 | i.`image_type`,
58 | i.`image_caption`,
59 | i.`image_source`,
60 | s.`source_id`,
61 | s.`source_name`,
62 | u.`user_id`,
63 | u.`user_name`
64 | FROM
65 | `post_images` pi
66 | INNER JOIN
67 | `posts` p ON p.`post_id` = pi.`post_id`
68 | INNER JOIN
69 | `images` i ON i.`image_id` = pi.`image_id`
70 | LEFT JOIN
71 | `sources` s ON s.`source_id` = p.`source_id`
72 | LEFT JOIN
73 | `users` u ON u.`user_id` = p.`user_id`
74 | WHERE
75 | pi.`post_id` = postId;
76 | END;
77 | ELSE
78 | /* Otherwise, just update */
79 | BEGIN
80 | DECLARE imageId, postDataId INT;
81 | DECLARE updateDone INT DEFAULT FALSE;
82 | DECLARE postData CURSOR FOR
83 | SELECT
84 | `pd_id`,
85 | `image_id`
86 | FROM
87 | `post_data`
88 | WHERE
89 | `post_id` = postId;
90 |
91 | DECLARE CONTINUE HANDLER FOR NOT FOUND SET updateDone = TRUE;
92 |
93 | OPEN postData;
94 | WHILE updateDone = FALSE DO
95 |
96 | FETCH postData INTO postDataId, imageId;
97 |
98 | UPDATE
99 | `post_data` pd
100 | INNER JOIN
101 | `posts` p ON p.`post_id` = postId
102 | INNER JOIN
103 | `images` i ON i.`image_id` = imageId
104 | LEFT JOIN
105 | `sources` s ON s.`source_id` = p.`source_id`
106 | LEFT JOIN
107 | `users` u ON u.`user_id` = p.`user_id`
108 | SET
109 | pd.`post_title` = p.`post_title`,
110 | pd.`post_keywords` = p.`post_keywords`,
111 | pd.`post_nsfw` = p.`post_nsfw`,
112 | pd.`post_score` = p.`post_score`,
113 | pd.`post_visible` = p.`post_visible`,
114 | pd.`user_id` = u.`user_id`,
115 | pd.`user_name` = u.`user_name`,
116 | pd.`source_id` = s.`source_id`,
117 | pd.`source_name` = s.`source_name`,
118 | pd.`image_caption` = i.`image_caption`,
119 | pd.`image_source` = i.`image_source`
120 | WHERE
121 | pd.`pd_id` = postDataId;
122 |
123 |
124 | END WHILE;
125 |
126 | END;
127 | END IF;
128 |
129 | END //
--------------------------------------------------------------------------------
/sql/proc_UpdatePostDataSortHot.sql:
--------------------------------------------------------------------------------
1 | /**
2 | * Updates denormalized post data from source of truth tables
3 | */
4 | DROP PROCEDURE IF EXISTS `proc_UpdatePostDataSortHot`;
5 |
6 | DELIMITER //
7 |
8 | CREATE PROCEDURE `proc_UpdatePostDataSortHot` ()
9 | BEGIN
10 |
11 | DECLARE sourcesDone BOOLEAN DEFAULT FALSE;
12 | DECLARE minDate INT DEFAULT UNIX_TIMESTAMP();
13 | DECLARE sourceId INT;
14 |
15 | DECLARE sources CURSOR FOR
16 | SELECT
17 | `source_id`
18 | FROM
19 | `sources`
20 | WHERE
21 | `source_enabled` = 1;
22 |
23 | DECLARE CONTINUE HANDLER FOR NOT FOUND SET sourcesDone = TRUE;
24 |
25 | OPEN sources;
26 |
27 | WHILE sourcesDone = FALSE DO
28 |
29 | FETCH sources INTO sourceId;
30 |
31 | BEGIN
32 |
33 | DECLARE postsDone BOOLEAN DEFAULT FALSE;
34 | DECLARE posts CURSOR FOR
35 | SELECT
36 | `post_id`,
37 | `post_score`,
38 | `post_date`
39 | FROM
40 | `post_data`
41 | WHERE
42 | `source_id` = sourceId;
43 |
44 | DECLARE CONTINUE HANDLER FOR NOT FOUND SET postsDone = TRUE;
45 |
46 | OPEN posts;
47 |
48 | WHILE postsDone = FALSE DO
49 |
50 | BEGIN
51 |
52 | DECLARE ordering,
53 | sign,
54 | seconds,
55 | postId,
56 | postScore,
57 | postDate INT;
58 |
59 | FETCH posts INTO postId, postScore, postDate;
60 |
61 | SET ordering = LOG10(GREATEST(ABS(postScore), 1));
62 | SET seconds = postDate - 1134028003;
63 |
64 | CASE
65 | WHEN postScore > 1 THEN
66 | SET sign = 1;
67 | WHEN @postScore < 0 THEN
68 | SET sign = -1;
69 | ELSE
70 | SET sign = 0;
71 | END CASE;
72 |
73 | UPDATE
74 | `post_data`
75 | SET
76 | `sort_hot` = ROUND(sign * ordering + seconds / 45000, 7)
77 | WHERE
78 | `post_id` = postId;
79 |
80 | END;
81 |
82 | END WHILE;
83 |
84 | END;
85 |
86 | END WHILE;
87 |
88 | CLOSE sources;
89 |
90 | END //
--------------------------------------------------------------------------------
/static/js/App.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 |
4 | import Router from './controls/Routes.js';
5 | import TplHelpers from './controls/TplHelpers';
6 | import QueryOptionCollection from './model/QueryOptionCollection';
7 | import ImageCollection from './model/ImageCollection';
8 | import FiltersView from './view/FiltersView';
9 | import SearchView from './view/SearchView';
10 | import SidebarView from './view/SidebarView';
11 | import UploadView from './view/UploadView';
12 | import ImageView from './view/ImageView';
13 | import UserView from './view/UserView';
14 | import DragDropView from './view/DragDropView';
15 | import GalleryView from './view/GalleryView';
16 | import MyGalleriesView from './view/MyGalleriesView';
17 | import ImageViewer from './view/ImageViewer';
18 | import NavView from './view/NavView';
19 |
20 | // Import stylesheets for bundling
21 | import '../scss/styles.scss';
22 | import '../scss/mobile.scss';
23 |
24 | // Amount of time to wait on user to stop making changes before firing off requests
25 | const UPDATE_DELAY = 1000;
26 | const HAS_CONTENT = 'hasContent';
27 | const HIDDEN = 'hidden';
28 | const MODAL_OPEN_CLASS = 'modal-open';
29 |
30 | var AppView = Backbone.View.extend({
31 |
32 | views: {},
33 | collections: {},
34 | router: new Router(),
35 |
36 | $body: $('body'),
37 | $title: $('#title'),
38 | $sidebar: $('#supporting'),
39 |
40 | _delayTimer: null,
41 |
42 | initialize: function() {
43 |
44 | var self = this;
45 |
46 | // Global collections
47 | this.collections = {
48 | sources: new QueryOptionCollection(),
49 | images: new ImageCollection(this.router)
50 | };
51 |
52 | // Bootstrap data
53 | this.collections.sources.reset(window.sources);
54 | if (window.startUp instanceof Array) {
55 | this.collections.images.reset(window.startUp);
56 | }
57 |
58 | var sidebar = new SidebarView(),
59 | upload = new UploadView(this.router),
60 | filters = new FiltersView({
61 | el: $('.filters').get(0),
62 | collection: this.collections.sources
63 | }),
64 | search = new SearchView(sidebar, this.collections.images, filters, this.router, upload);
65 |
66 | // Views
67 | this.views = {
68 | sidebar: sidebar,
69 | filters: filters,
70 | images: new ImageView({ collection: this.collections.images }),
71 | search: search,
72 | user: new UserView(sidebar, this.collections.images, this.router),
73 | dragdrop: new DragDropView(upload, search),
74 | upload: upload,
75 | gallery: new GalleryView(this.router, sidebar),
76 | myGalleries: new MyGalleriesView(this.router, upload),
77 | imageViewer: new ImageViewer(this.router, this.collections.images),
78 | nav: new NavView({ collection: this.collections.images })
79 | };
80 |
81 | // If the startup blob has a specific view associated, kick it off
82 | setTimeout(function() {
83 | if (window.startUp && 'view' in window.startUp) {
84 | self.views[window.startUp.view].initData(window.startUp);
85 | }
86 | }, 10);
87 |
88 | },
89 |
90 | setTitle: function(title) {
91 | if (title) {
92 | this.$title.html(title).removeClass(HIDDEN);
93 | document.title = this.$title.text() + ' - redditbooru';
94 | } else {
95 | this.$title.html('').addClass(HIDDEN);
96 | document.title = 'redditbooru - a place where cute girls come to meet';
97 | }
98 | },
99 |
100 | setSidebar: function(content) {
101 | if (content) {
102 | this.$sidebar.addClass(HAS_CONTENT).html(content);
103 | } else {
104 | this.$sidebar.removeClass(HAS_CONTENT).empty();
105 | }
106 | this.views.images.calculateWindowColumns();
107 | },
108 |
109 | toggleModalMode(modalOpen) {
110 | this.$body.toggleClass(MODAL_OPEN_CLASS, modalOpen);
111 | }
112 |
113 | });
114 |
115 | var App = new AppView();
116 | if (!window._App) {
117 | window._App = App;
118 | }
119 |
120 | export default window._App;
--------------------------------------------------------------------------------
/static/js/HashRedirect.js:
--------------------------------------------------------------------------------
1 | (function(undefined) {
2 |
3 | // quick underscore fill because this file needs to execute quickly
4 | var _ = {
5 | each: function(collection, callback) {
6 | for (var i in collection) {
7 | if (collection.hasOwnProperty(i)) {
8 | callback(collection[i], i);
9 | }
10 | }
11 | }
12 | },
13 |
14 | displayUploadDialog = function(url) {
15 | if (!window._App) {
16 | setTimeout(() => { displayUploadDialog(url); }, 10);
17 | } else {
18 | window._App.views.upload.openWithUpload(url);
19 | }
20 | },
21 |
22 | parseQueryString = function(qs) {
23 | var params = {};
24 | qs = qs.split('&');
25 |
26 | _.each(qs, function(item) {
27 | var kvp = item.split('=', 2);
28 | if (kvp.length === 1) {
29 | params[kvp[0]] = true;
30 | } else {
31 | params[kvp[0]] = kvp[1];
32 | }
33 | });
34 |
35 | return params;
36 | },
37 |
38 | hash = window.location.hash.replace('#!', '') || '',
39 | params = {};
40 |
41 | // Redirects old RB1 URLs to the equivelent RB2 URL
42 | if (window.location.hash || window.location.href.indexOf('?')) {
43 |
44 | // if there is no hash, we're on a dialog request. process and bail
45 | if (!hash.length) {
46 |
47 | qs = window.location.href.split('?');
48 | if (qs.length > 1) {
49 | params = parseQueryString(qs[1]);
50 | if ('dialog' in params) {
51 | switch (params.dialog) {
52 | case 'upload':
53 | displayUploadDialog(params.rehost ? decodeURIComponent(params.rehost) : undefined);
54 | break;
55 | case 'screensaver':
56 | setTimeout(function() {
57 | RB.App.views.imageViewer.startScreensaver();
58 | }, 1000);
59 | break;
60 | }
61 | }
62 | }
63 |
64 | } else {
65 |
66 | params = parseQueryString(hash);
67 |
68 | // dialog is handled through internal object routing, everything else is redirected to search
69 | if ('dialog' in params) {
70 | if ('upload' === params.dialog) {
71 | displayUploadDialog();
72 | }
73 | } else {
74 | if ('q' in params && params.q.indexOf('http') === 0) {
75 | params.imageUri = params.q;
76 | delete params.q;
77 | }
78 |
79 | if ('source' in params) {
80 | params.sources = params.source;
81 | delete params.source;
82 | }
83 |
84 | qs = [];
85 | _.each(params, function(value, key) {
86 | qs.push(key + (value ? '=' + value : ''));
87 | });
88 |
89 | window.location.href = '/search/?' + qs.join('&');
90 | }
91 |
92 | }
93 |
94 | }
95 |
96 | }());
97 |
--------------------------------------------------------------------------------
/static/js/controls/Cookie.js:
--------------------------------------------------------------------------------
1 | export default {
2 |
3 | bake: function(name, value, expires, path, domain) {
4 | var cookie = name + "=" + escape(value) + ";";
5 |
6 | if (expires) {
7 | // If it's a date
8 | if(expires instanceof Date) {
9 | // If it isn't a valid date
10 | if (isNaN(expires.getTime())) {
11 | expires = new Date();
12 | }
13 | } else {
14 | expires = new Date(new Date().getTime() + parseInt(expires) * 1000 * 60 * 60 * 24);
15 | }
16 | }
17 | cookie += "expires=" + expires.toGMTString() + ";";
18 |
19 | if (path) {
20 | cookie += "path=" + path + ";";
21 | }
22 |
23 | if (domain) {
24 | cookie += "domain=" + domain + ";";
25 | }
26 |
27 | document.cookie = cookie;
28 | },
29 |
30 | eat: function(name) {
31 | var regexp = new RegExp("(?:^" + name + "|;\s*"+ name + ")=(.*?)(?:;|$)", "g"),
32 | result = regexp.exec(document.cookie);
33 | return (result === null) ? null : result[1];
34 | }
35 |
36 | };
--------------------------------------------------------------------------------
/static/js/controls/GaTracker.js:
--------------------------------------------------------------------------------
1 | export default function(category, event, data) {
2 | if (window._gaq) {
3 | window._gaq.push([ '_trackEvent', category, event ]);
4 | }
5 | };
--------------------------------------------------------------------------------
/static/js/controls/ProgressCircle.js:
--------------------------------------------------------------------------------
1 | var STEP = Math.PI * 0.5,
2 | FULL_CIRCLE = Math.PI * 2,
3 | CIRCLE_TOP = Math.PI * 1.5;
4 |
5 | var ProgressCircle = function($el, max, diameter, color, lineThickness) {
6 | var canvas = document.createElement('canvas'),
7 | context = canvas.getContext('2d');
8 |
9 | this.value = 0;
10 | this.max = max;
11 | this.center = diameter / 2;
12 |
13 | this.diameter = (diameter - lineThickness) / 2;
14 | canvas.width = diameter;
15 | canvas.height = diameter;
16 | this.canvas = canvas;
17 | context.strokeStyle = color;
18 | context.lineWidth = lineThickness;
19 | this.context = context;
20 |
21 | $el.append(canvas);
22 | this._draw();
23 |
24 | };
25 |
26 | ProgressCircle.prototype.progress = function(value) {
27 | this.value = value;
28 | this._draw();
29 | };
30 |
31 | ProgressCircle.prototype._draw = function() {
32 | var canvas = this.canvas,
33 | context = this.context,
34 | arc = this.value / this.max;
35 |
36 | arc = arc > 1 ? 1 : arc;
37 | arc = FULL_CIRCLE * arc;
38 |
39 | context.beginPath();
40 | context.arc(this.center, this.center, this.diameter, 0, arc);
41 | context.stroke();
42 |
43 | };
44 |
45 | export default ProgressCircle;
--------------------------------------------------------------------------------
/static/js/controls/Routes.js:
--------------------------------------------------------------------------------
1 | import _ from 'underscore';
2 |
3 | var PATH_DELIMITER = '/',
4 | PARAM_MARKER = ':',
5 | QS_MARKER = '?',
6 | MODE_STANDARD = 0,
7 | MODE_PARAM = 1;
8 |
9 |
10 | var Router = function(handleCurrentState) {
11 |
12 | this._routes = {};
13 | this._supportsHistory = 'history' in window;
14 |
15 | if (this._supportsHistory) {
16 | window.addEventListener('popstate', function(evt) {
17 |
18 | });
19 | }
20 |
21 | };
22 |
23 | Router.prototype.addRoute = function(name, path, callback) {
24 |
25 | this._routes[name] = {
26 | path: path,
27 | callbacks: []
28 | };
29 |
30 | _.defaults(this._routes[name], this._compilePath(path));
31 |
32 | if (typeof callback === 'function') {
33 | this._routes[name].callbacks.push(callback);
34 | }
35 |
36 |
37 | };
38 |
39 | Router.prototype.on = function(route, callback) {
40 | if (route in this._routes) {
41 | this._routes[route].callbacks.push(callback);
42 | }
43 | };
44 |
45 | Router.prototype.go = function(route, params) {
46 |
47 | var url,
48 | oldUrl,
49 | qs = [];
50 |
51 | // Look for the route by name in the list
52 | if (route in this._routes) {
53 |
54 | url = this._routes[route].path;
55 | oldUrl = url;
56 |
57 | _.each(this._routes[route].callbacks, function(callback) {
58 | var addParams = callback(params);
59 |
60 | // If the callback returned additional parameters, add them to the list
61 | // so that all values are correctly represented in the final URL
62 | if (typeof addParams === 'object') {
63 | _.defaults(params, addParams);
64 | }
65 | });
66 |
67 | if (this._supportsHistory) {
68 | _.each(params, function(value, key) {
69 | url = url.replace(new RegExp('(\\*|\\:)' + key), value);
70 |
71 | // If the parameter wasn't found as part of the route, throw it on the query string
72 | if (oldUrl === url) {
73 | qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
74 | }
75 |
76 | oldUrl = url;
77 | });
78 |
79 | url = qs.length ? url + '?' + qs.join('&') : url;
80 | window.history.pushState({
81 | route: route,
82 | params: params
83 | }, null, url);
84 | }
85 |
86 | } else {
87 |
88 | // Otherwise, match against a path
89 | route = this._getRouteFromPath(route);
90 |
91 | }
92 | };
93 |
94 | Router.prototype._getRouteFromPath = function(path) {
95 |
96 | var i, result, route,
97 | params = {};
98 |
99 | for (var i in this._routes) {
100 | if (this._routes.hasOwnProperty(i)) {
101 | route = this._routes[i];
102 | result = route.regEx.exec(path);
103 |
104 | if (result) {
105 | _.each(route.map, function(name, index) {
106 | params[name] = result[index];
107 | });
108 |
109 | this.go(i, params);
110 | return;
111 | }
112 | }
113 | }
114 |
115 | };
116 |
117 | /**
118 | * Generates a regular expression so that a path can be tracked back to its route
119 | */
120 | Router.prototype._compilePath = function(path) {
121 | var regEx = '^',
122 | paramName = '',
123 | mode = MODE_STANDARD,
124 | i = 0,
125 | count = path.length,
126 | character,
127 | paramCount = 1,
128 | paramMap = {},
129 | hasQueryString = false;
130 |
131 | for (; i < count; i++) {
132 |
133 | // If this route has already parsed a querystring placeholder, throw an error
134 | // because that must be the last part of a route if it's present
135 | if (hasQueryString) {
136 | throw 'Cannot have additional path/parameters after a query string marker';
137 | return;
138 | }
139 |
140 | character = path.charAt(i);
141 |
142 | if (MODE_PARAM === mode) {
143 | // A parameter marker in a character is invalid
144 | if (PARAM_MARKER === character) {
145 | throw 'Invalid character in route path';
146 | return;
147 | } else if (PATH_DELIMITER === character) {
148 | regEx = regEx.concat('([^\\/]+)\\/');
149 | paramMap[paramCount] = paramName;
150 | paramName = '';
151 | mode = MODE_STANDARD;
152 | paramCount++;
153 | } else {
154 | paramName = paramName.concat(character);
155 | }
156 | } else {
157 | if (PARAM_MARKER === character) {
158 | mode = MODE_PARAM;
159 | } else if (QS_MARKER === character) {
160 | hasQueryString = true;
161 | regEx = regEx.concat('([\w]+)');
162 | } else {
163 | regEx = regEx.concat(character);
164 | }
165 | }
166 | }
167 |
168 | // If the route ended on a parameter, handle it
169 | if (paramName.length > 0) {
170 | regEx = regEx.concat('([^\\/]+)');
171 | paramMap[paramCount] = paramName;
172 | }
173 |
174 | regEx = new RegExp(regEx.concat('$'), 'ig');
175 |
176 | return {
177 | regEx: regEx,
178 | map: paramMap
179 | };
180 |
181 | };
182 |
183 | export default Router;
--------------------------------------------------------------------------------
/static/js/controls/TplHelpers.js:
--------------------------------------------------------------------------------
1 | import Handlebars from 'handlebars/runtime';
2 | import _ from 'underscore';
3 |
4 | var MINUTE_SECONDS = 60,
5 | HOUR_SECONDS = MINUTE_SECONDS * 60,
6 | DAY_SECONDS = HOUR_SECONDS * 24,
7 | MONTH_SECONDS = DAY_SECONDS * 30,
8 | YEAR_SECONDS = MONTH_SECONDS * 12;
9 |
10 | Handlebars.registerHelper('relativeTime', function(dateStamp) {
11 | var delta = Math.round(Date.now() / 1000) - dateStamp,
12 | unit = 'second',
13 | amount = delta;
14 |
15 | if (delta >= YEAR_SECONDS) {
16 | amount = Math.floor(delta / YEAR_SECONDS);
17 | unit = 'year';
18 | } else if (delta >= MONTH_SECONDS) {
19 | amount = Math.floor(delta / MONTH_SECONDS);
20 | unit = 'month';
21 | } else if (delta >= DAY_SECONDS) {
22 | amount = Math.floor(delta / DAY_SECONDS);
23 | unit = 'day';
24 | } else if (delta >= HOUR_SECONDS) {
25 | amount = Math.floor(delta / HOUR_SECONDS);
26 | unit = 'hour';
27 | } else if (delta >= MINUTE_SECONDS) {
28 | amount = Math.floor(delta / MINUTE_SECONDS);
29 | unit = 'minute';
30 | }
31 |
32 | return amount + ' ' + unit + (amount !== 1 ? 's' : '');
33 |
34 | });
35 |
36 | Handlebars.registerHelper('voteStatus', function(vote) {
37 | return vote === -1 ? 'downvote' :
38 | vote === 1 ? 'upvote' :
39 | vote === 0 ? 'no-vote' : 'no-data';
40 | });
41 |
42 | Handlebars.registerHelper('inTestBucket', function(a, b, c) {
43 | if (_.has(window.tests, a.hash.key)) {
44 | return window.tests[a.hash.key] === a.hash.value ? a.fn(this) : '';
45 | }
46 | });
47 |
48 | Handlebars.registerHelper('sep', function(context) {
49 | if (_.isObject(context) && !context.data.last) {
50 | return context.fn(context, {});
51 | }
52 | });
53 |
54 | // Lazily adding an export so this can be included and picked up during compile-time
55 | // TODO - something less shitty
56 | export default null;
--------------------------------------------------------------------------------
/static/js/controls/Uploader.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 |
3 | import ProgressCircle from './ProgressCircle';
4 |
5 | var $globalUpload = $('#globalUpload'),
6 | $upload = $globalUpload.find('[type="file"]'),
7 | $globalProgress = $('#globalUploaderProgress'),
8 | progress = new ProgressCircle($globalProgress, 100, 260, '#e94e77', 20),
9 |
10 | ACCEPTED_FORMATS = [
11 | 'image/jpeg',
12 | 'image/gif',
13 | 'image/png'
14 | ],
15 |
16 | triggerFileDialog = function() {
17 | var self = this;
18 | $upload
19 | .on('change', function(evt) {
20 | uploadChange.call(self, evt);
21 | })
22 | .click();
23 | },
24 |
25 | uploadFile = function(file) {
26 | var xhr = new XMLHttpRequest(),
27 | self = this,
28 | formData = new FormData();
29 |
30 | this.onBegin(this.uploadId, file.name);
31 |
32 | if (this.showProgress) {
33 | progress.progress(0);
34 | $globalProgress.fadeIn();
35 | }
36 |
37 | // Validate the type
38 | if (ACCEPTED_FORMATS.indexOf(file.type) === -1) {
39 | alert('Image must be a JPEG, GIF, or PNG');
40 | return;
41 | }
42 |
43 | if (typeof this.onProgress === 'function') {
44 | xhr.upload.addEventListener('progress', function(evt) {
45 | var percent = Math.round(evt.loaded / evt.total * 100);
46 | if (self.showProgress) {
47 | progress.progress(percent);
48 | }
49 | self.onProgress(percent, self.uploadId);
50 | });
51 | }
52 |
53 | xhr.addEventListener('readystatechange', function() {
54 | if (xhr.readyState === 4) {
55 | try {
56 | self.onComplete(JSON.parse(xhr.responseText));
57 | } catch (e) {
58 | // do nothing because I hate error management. Someday...
59 | }
60 |
61 | if (self.showProgress) {
62 | $globalProgress.fadeOut();
63 | }
64 |
65 | }
66 | });
67 |
68 | xhr.open('POST', this.endpoint, true);
69 | xhr.setRequestHeader('X-FileName', file.name);
70 | formData.append('upload', file);
71 | formData.append('uploadId', this.uploadId);
72 | xhr.send(formData);
73 |
74 | $upload.off('change');
75 |
76 | },
77 |
78 | uploadChange = function(evt) {
79 | var files = evt.target.files,
80 | file;
81 |
82 | if (files.length > 0) {
83 |
84 | file = files[0];
85 |
86 | // Kick off the upload
87 | uploadFile.call(this, file);
88 |
89 | }
90 | };
91 |
92 | var Uploader = function(onBegin, onComplete, onProgress, file, endpoint, showProgress) {
93 | this.onBegin = onBegin;
94 | this.onComplete = onComplete;
95 | this.onProgress = onProgress || null;
96 | this.uploadId = Date.now();
97 | this.endpoint = endpoint || '/upload/?action=upload';
98 | this.showProgress = showProgress || false;
99 |
100 | if (!file) {
101 | triggerFileDialog.call(this);
102 | } else {
103 | uploadFile.call(this, file);
104 |
105 | }
106 | };
107 |
108 | Uploader.showGlobalProgress = function() {
109 | $globalProgress.show();
110 | };
111 |
112 | Uploader.hideGlobalProgress = function() {
113 | $globalProgress.hide();
114 | };
115 |
116 | Uploader.getGlobalProgress = function() {
117 | return progress;
118 | };
119 |
120 | // TODO - setup a singelton utility
121 | if (!window._Uploader) {
122 | window._Uploader = Uploader;
123 | }
124 |
125 | export default window._Uploader;
--------------------------------------------------------------------------------
/static/js/controls/overlay.js:
--------------------------------------------------------------------------------
1 | const TRANSITION_END = 'transitionend';
2 | const IS_FIREFOX = window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
3 |
4 | function onTransitionEnd(evt) {
5 | var el = evt.target;
6 | el.style.display = 'none';
7 | if (!IS_FIREFOX) {
8 | el.removeEventListener(TRANSITION_END, onTransitionEnd);
9 | }
10 | }
11 |
12 | export function initOverlay(el) {
13 | el.style.display = 'none';
14 | }
15 |
16 | export function showOverlay(el, callback) {
17 | el.style.display = 'block';
18 | // Run the callback on the next redraw, unless firefox. Fuck those guys...
19 | if (IS_FIREFOX) {
20 | callback();
21 | } else {
22 | window.requestAnimationFrame(callback);
23 | }
24 | }
25 |
26 | export function hideOverlay(el) {
27 | if (IS_FIREFOX) {
28 | onTransitionEnd({ target: el });
29 | } else {
30 | window.requestAnimationFrame(() => {
31 | el.addEventListener(TRANSITION_END, onTransitionEnd);
32 | });
33 | }
34 | }
--------------------------------------------------------------------------------
/static/js/model/Image.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 |
3 | export default Backbone.Model.extend({
4 | defaults: function() {
5 | return {
6 | id: null,
7 | cdnUrl: null,
8 | width: null,
9 | height: null,
10 | sourceId: null,
11 | sourceName: null,
12 | baseUrl: null,
13 | postId: null,
14 | title: null,
15 | dateCreated: null,
16 | externalId: null,
17 | score: null,
18 | userId: null,
19 | userName: null,
20 | nsfw: null,
21 | thumb: null,
22 | idxInAlbum: null,
23 | age: null,
24 | rendered: false,
25 | visible: false,
26 | distance: null,
27 | caption: null,
28 | sourceUrl: null,
29 | userVote: null
30 | };
31 | }
32 |
33 | });
--------------------------------------------------------------------------------
/static/js/model/ImageCollection.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import _ from 'underscore';
3 |
4 | import ImageModel from './Image';
5 |
6 | var API_PATH = '/images/',
7 | EVT_UPDATED = 'updated';
8 |
9 | export default Backbone.Collection.extend({
10 |
11 | model: ImageModel,
12 |
13 | // Params
14 | lastDate: 0,
15 | queryOptions: {},
16 | queryUrl: API_PATH,
17 |
18 | url: function() {
19 | var separator = this.queryUrl.indexOf('?') !== -1 ? '&' : '?',
20 | params = [];
21 |
22 | if (this.lastDate > 0) {
23 | params = [ 'afterDate=' + this.lastDate ];
24 | }
25 |
26 | return this.queryUrl + separator + params.join('&');
27 | },
28 |
29 | initialize: function(router) {
30 |
31 | _.extend(this, Backbone.Events);
32 | this.on('add', (item) => {
33 | this._checkLastDate(item);
34 | });
35 |
36 | this.on('reset', () => {
37 | this.lastDate = 0;
38 | _.each(this.models, (item) => {
39 | this._checkLastDate(item);
40 | });
41 | });
42 |
43 | if (router) {
44 | router.addRoute('home', '/');
45 | this.router = router;
46 | }
47 |
48 | if (typeof window.filters === 'object') {
49 | this.queryOptions = window.filters || {};
50 | this.queryUrl = this._buildQueryUrl();
51 | }
52 |
53 | },
54 |
55 | _checkLastDate: function(item) {
56 | var date = parseInt(item.attributes.dateCreated);
57 | if (date < this.lastDate || this.lastDate === 0) {
58 | this.lastDate = item.attributes.dateCreated;
59 | }
60 | },
61 |
62 | setQueryOption: function(name, value, noRequest) {
63 | var oldQueryUrl = this.queryUrl;
64 |
65 | if (typeof name === 'object') {
66 | this.queryOptions = name;
67 | noRequest = !!value;
68 | } else {
69 | this.queryOptions[name] = value;
70 | }
71 |
72 | this.queryUrl = this._buildQueryUrl();
73 |
74 | // If the query has changed, reset the paging and invalidate the current results
75 | if (oldQueryUrl !== this.queryUrl && !noRequest) {
76 | this.lastDate = 0;
77 | this.reset();
78 | this.loadNext();
79 | }
80 |
81 | },
82 |
83 | clearQueryOptions: function(noRequest) {
84 | var oldQueryUrl = this.queryUrl;
85 | this.queryOptions = {};
86 | this.queryUrl = this._buildQueryUrl();
87 |
88 | // If the query has changed, reset the paging and invalidate the current results
89 | if (oldQueryUrl !== this.queryUrl && !noRequest) {
90 | this.lastDate = 0;
91 | this.reset();
92 | this.loadNext();
93 | if (this.router) {
94 | this.router.go('home');
95 | }
96 | }
97 | },
98 |
99 | _buildQueryUrl: function() {
100 | var retVal = [];
101 | Object.keys(this.queryOptions).forEach((option) => {
102 | retVal.push(encodeURIComponent(option) + '=' + encodeURIComponent(this.queryOptions[option]));
103 | });
104 | return API_PATH + '?' + retVal.join('&');
105 | },
106 |
107 | loadNext: function(filter) {
108 | this.fetch({
109 | dataFilter: filter,
110 | success: _.bind(function() {
111 | this.trigger(EVT_UPDATED);
112 | }, this)
113 | });
114 | }
115 |
116 | });
--------------------------------------------------------------------------------
/static/js/model/QueryOption.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 |
3 | export default Backbone.Model.extend({
4 | defaults: function() {
5 | return {
6 | title: '',
7 | value: '',
8 | name: '',
9 | checked: false
10 | };
11 | }
12 | });
--------------------------------------------------------------------------------
/static/js/model/QueryOptionCollection.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 |
3 | import QueryOption from './QueryOption';
4 |
5 | export default Backbone.Collection.extend({
6 | model: QueryOption
7 | });
--------------------------------------------------------------------------------
/static/js/view/DragDropView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 | import _ from 'underscore';
4 |
5 | var HOVER = 'hover';
6 |
7 | export default Backbone.View.extend({
8 |
9 | el: '#dragdrop',
10 |
11 | initialize: function(uploadView, searchView) {
12 |
13 | var body = document.documentElement;
14 |
15 | // Temporary hack to get around what seems to be backbone returning the wrong element
16 | this.el = document.getElementById('dragdrop');
17 | this.$el = $(this.el);
18 |
19 | body.addEventListener('dragover', _.bind(this.handleDragOver, this));
20 | body.addEventListener('dragleave', _.bind(this.handleDragLeave, this));
21 | body.addEventListener('drop', _.bind(this.handleDrop, this));
22 |
23 | this.$el.find('.search').on('drop', _.bind(this.handleDrop, this));
24 |
25 | this.uploadView = uploadView;
26 | this.searchView = searchView;
27 |
28 | },
29 |
30 | handleDragOver: function(evt) {
31 | evt.stopPropagation();
32 | evt.preventDefault();
33 | this.$el.show();
34 | this.$el.find('.' + HOVER).removeClass(HOVER);
35 | this.$el.find(evt.target).addClass(HOVER);
36 | },
37 |
38 | handleDragLeave: function(evt) {
39 |
40 | evt.stopPropagation();
41 | evt.preventDefault();
42 |
43 | var position = this.$el.position(),
44 | width = this.$el.width(),
45 | height = this.$el.height();
46 |
47 | if (evt.clientX < position.left || evt.clientY < position.top
48 | || evt.clientX > position.left + width || evt.clientY > position.top + height) {
49 | this.$el.hide().find('.' + HOVER).removeClass(HOVER);
50 | }
51 |
52 | },
53 |
54 | handleDrop: function(evt) {
55 | evt.preventDefault();
56 | this.$el.hide();
57 | if (evt.dataTransfer && evt.dataTransfer.files.length > 0) {
58 | var file = evt.dataTransfer.files[0];
59 | if ($(evt.target).hasClass('upload')) {
60 | this.uploadView.openWithUpload(file);
61 | } else {
62 | this.searchView.uploadSearch(file);
63 | }
64 | }
65 | }
66 |
67 | });
--------------------------------------------------------------------------------
/static/js/view/FiltersView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 | import _ from 'underscore';
4 |
5 | import App from '../App';
6 |
7 | const EVT_UPDATE = 'update';
8 | const MODAL_CLASS = 'modal';
9 |
10 | export default Backbone.View.extend({
11 |
12 | $sources: null,
13 | $sizes: null,
14 | $saveFilters: null,
15 | $body: $('body'),
16 |
17 | isModal: false,
18 |
19 | events: {
20 | 'click button': 'handleRefreshClick',
21 | 'change input[type="checkbox"]': 'handleCheckChange'
22 | },
23 |
24 | initialize(collection) {
25 | this.$sources = this.$el.find('[name="sources"]');
26 | this.$sizes = this.$el.find('[name="sizes"]');
27 | this.$saveFilters = this.$el.find('#save-filters');
28 | this.$body
29 | .on('click', '#content .show-filters', _.bind(this.showFiltersModal, this))
30 | .on('click', _.bind(this.closeFiltersModal, this));
31 | this.render();
32 | _.extend(this, Backbone.Events);
33 | },
34 |
35 | handleRefreshClick(evt) {
36 | var values = {};
37 | var changed = false;
38 | this.$sources.each((index, item) => {
39 | values[item.getAttribute('value')] = !!item.checked;
40 | });
41 |
42 | this.collection.each((item) => {
43 | var value = values[item.attributes.value];
44 | changed = changed || item.attributes.checked === value;
45 | item.attributes.checked = value;
46 | });
47 |
48 | if (changed) {
49 | this.trigger(EVT_UPDATE, this.$saveFilters.is(':checked'));
50 | }
51 |
52 | if (this.isModal) {
53 | this.hideFiltersModal();
54 | }
55 |
56 | },
57 |
58 | showFiltersModal(evt) {
59 | let $el = this.$el.addClass(MODAL_CLASS).detach();
60 | App.toggleModalMode(true);
61 | this.$body.append($el);
62 | this.isModal = true;
63 | evt.stopPropagation();
64 | },
65 |
66 | hideFiltersModal() {
67 | this.$el.removeClass(MODAL_CLASS);
68 | this.$el.detach().appendTo($('#sources'));
69 | App.toggleModalMode(false);
70 | },
71 |
72 | handleCheckChange(evt) {
73 | var $target = $(evt.currentTarget);
74 | var selector = '[name="' + $target.attr('name') + '"]';
75 | var $set = this.$el.find(selector);
76 | var checked = $target.is(':checked');
77 |
78 | if ($target.val() === 'all') {
79 | $set.prop('checked', checked);
80 | } else {
81 | // If only one checkbox isn't checked, it's the "all", so check it
82 | if (checked && this.$el.find(selector + ':not(:checked)').length === 1) {
83 | this.$el.find(selector + '[value="all"]').prop('checked', checked);
84 | } else {
85 | this.$el.find(selector + '[value="all"]').prop('checked', false);
86 | }
87 | }
88 | },
89 |
90 | closeFiltersModal(evt) {
91 | if (this.isModal && $(evt.target).closest('.filters').length === 0) {
92 | this.hideFiltersModal();
93 | }
94 | }
95 |
96 | });
--------------------------------------------------------------------------------
/static/js/view/GalleryView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 |
4 | import App from '../App';
5 | import gallery from '@views/gallery.hbs';
6 |
7 | const RESIZE_BUTTON_ID = 'resizeGalleryImages';
8 | const GALLERY = 'gallery';
9 | // Should be calculated, but then again, a lot of things should be a lot of things
10 | const HEADER_HEIGHT = 150;
11 |
12 | export default Backbone.View.extend({
13 |
14 | initialize(router, sidebar) {
15 | this.sidebar = sidebar;
16 | const routeHandler = this.handleRoute.bind(this);
17 | router.addRoute('galleryNew', '/gallery/:id/:title', routeHandler);
18 | router.addRoute('galleryOld', '/gallery/:id', routeHandler);
19 | $('#images').on('click', `#${RESIZE_BUTTON_ID}`, this.handleExpandClick.bind(this));
20 | },
21 |
22 | initData(data) {
23 | this.displayGallery(data.images);
24 | },
25 |
26 | handleRoute(data) {
27 | this.sidebar.dismiss();
28 |
29 | if (!Array.isArray(data)) {
30 | if ('id' in data) {
31 |
32 | var id = data.id;
33 |
34 | // If there's a title, base convert the ID from 36 back to 10
35 | if ('title' in data) {
36 | id = parseInt(id, 36);
37 | }
38 |
39 | $.ajax({
40 | url: '/images/?postId=' + id,
41 | dataType: 'json',
42 | success: this.displayGallery.bind(this)
43 | });
44 | }
45 | }
46 |
47 | },
48 |
49 | displayGallery(data) {
50 | if (Array.isArray(data) && data.length > 0) {
51 | App.setTitle(data[0].title);
52 |
53 | // Add in resize info
54 | const viewportHeight = $(window).height() - HEADER_HEIGHT;
55 | let wasResized = false;
56 | data.forEach(image => {
57 | if (image.height > viewportHeight) {
58 | image.height = viewportHeight;
59 | wasResized = true;
60 | }
61 | });
62 |
63 | $('#images')
64 | .addClass(GALLERY)
65 | .html(gallery({ images: data, wasResized }));
66 |
67 | }
68 | },
69 |
70 | // Oh what a hack this is
71 | handleExpandClick(evt) {
72 | let buttonText = 'Shrink Images';
73 | document.querySelectorAll('.gallery-image img').forEach(image => {
74 | const styleAttr = image.getAttribute('style');
75 | const cachedStyleAttr = image.getAttribute('data-style');
76 |
77 | if (styleAttr) {
78 | image.removeAttribute('style');
79 | image.setAttribute('data-style', styleAttr);
80 | buttonText = 'Shrink Images';
81 | } else {
82 | // There's really no reason to remove the data attribute,
83 | // so just leave it
84 | image.setAttribute('style', cachedStyleAttr);
85 | buttonText = 'Expand Images';
86 | }
87 | });
88 | $(`#${RESIZE_BUTTON_ID}`).text(buttonText);
89 | }
90 |
91 | });
--------------------------------------------------------------------------------
/static/js/view/MyGalleriesView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 | import _ from 'underscore';
4 |
5 | export default Backbone.View.extend({
6 |
7 | uploadForm: null,
8 | router: null,
9 |
10 | initialize: function(router, uploadForm) {
11 | this.uploadForm = uploadForm;
12 | this.router = router;
13 | this._attachEventListeners();
14 | },
15 |
16 | _attachEventListeners: function() {
17 | $('body').on('click', '.user-galleries .edit-album', _.bind(this.handleEditClick, this));
18 | },
19 |
20 | handleEditClick: function(evt) {
21 | var $parent = $(evt.currentTarget).closest('li'),
22 | galleryId = $parent.data('id');
23 | $.getJSON(`/api/images/?postId=${galleryId}`).then(data => {
24 | if (data.length) {
25 | const first = data[0];
26 | const editData = {
27 | age: first.age,
28 | dateCreate: first.dateCreated,
29 | id: first.postId,
30 | externalId: first.externalId,
31 | title: first.title,
32 | images: data
33 | };
34 | this.uploadForm.loadGallery(editData);
35 | }
36 | });
37 | }
38 |
39 | });
--------------------------------------------------------------------------------
/static/js/view/NavView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 |
4 | import App from '../App';
5 |
6 | const MENU_OPEN_CLASS = 'menu-open';
7 |
8 | export default Backbone.View.extend({
9 |
10 | el: 'header',
11 |
12 | initialize() {
13 | this.$el.on('click', '.open-menu', (evt) => {
14 | App.$body.toggleClass(MENU_OPEN_CLASS);
15 | });
16 |
17 | this.collection.on('updated', (evt) => {
18 | App.$body.removeClass(MENU_OPEN_CLASS);
19 | });
20 | }
21 |
22 | });
--------------------------------------------------------------------------------
/static/js/view/QueryOptionsView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 |
3 | import queryOptionItem from '../../../../views/queryOptionItem.hbs';
4 |
5 | var EVT_UPDATE = 'update';
6 |
7 | export default Backbone.View.extend({
8 |
9 | collection: null,
10 | $el: null,
11 | template: queryOptionItem,
12 |
13 | events: {
14 | 'change .queryOption': 'handleQueryOptionChange'
15 | },
16 |
17 | initialize: function($el, collection) {
18 | this.collection = collection;
19 | this.$el = $el;
20 | this.name = $el.attr('id');
21 | this.render();
22 | _.extend(this, Backbone.Events);
23 | },
24 |
25 | render: function() {
26 | /*
27 | var tplData = {
28 | type: 'checkbox',
29 | items: this.collection.toJSON(),
30 | name: this.name
31 | };
32 | this.$el.html(this.template(tplData));
33 | */
34 | },
35 |
36 | handleQueryOptionChange: function(evt) {
37 | var value = evt.target.value,
38 | checked = evt.target.checked,
39 | item = this._getItemForValue(value);
40 |
41 | if (null !== item) {
42 | item.attributes.checked = checked;
43 | this.trigger(EVT_UPDATE, item);
44 | }
45 | },
46 |
47 | _getItemForValue: function(value) {
48 | var retVal = null;
49 | this.collection.each(function(item) {
50 | if (item.attributes.value == value) {
51 | retVal = item;
52 | }
53 | });
54 | return retVal;
55 | }
56 |
57 | });
--------------------------------------------------------------------------------
/static/js/view/SidebarView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import $ from 'jquery';
3 |
4 | import App from '../App';
5 |
6 | export default Backbone.View.extend({
7 |
8 | el: '#supporting',
9 |
10 | populate: function(html, owner) {
11 | this.$el.html(html).addClass('hasContent');
12 |
13 | // TODO - this should probably be event controlled
14 | App.views.images.calculateWindowColumns();
15 | },
16 |
17 | dismiss: function() {
18 | this.$el.html('').removeClass('hasContent');
19 | // TODO - this should probably be event controlled
20 | App.views.images.calculateWindowColumns();
21 | }
22 |
23 | });
--------------------------------------------------------------------------------
/static/js/view/UserView.js:
--------------------------------------------------------------------------------
1 | import Backbone from 'backbone';
2 | import _ from 'underscore';
3 |
4 | export default Backbone.View.extend({
5 |
6 | initialize: function(sidebar, imageCollection, router) {
7 | this.imageCollection = imageCollection;
8 | router.on('route:user', _.bind(this.onRoute, this));
9 | },
10 |
11 | onRoute: function(userName) {
12 | this.imageCollection.setQueryOption('user', userName, true);
13 | }
14 |
15 | });
--------------------------------------------------------------------------------
/static/scss/filters.scss:
--------------------------------------------------------------------------------
1 | @import "mixins";
2 |
3 | $filters-bg: rgba(#fff, 0.95);
4 |
5 | @media screen and (min-width: 568px) {
6 | #sources {
7 | background: $filters-bg;
8 | cursor: default;
9 | position: fixed;
10 | top: 45px;
11 | left: 0;
12 | bottom: 0;
13 | right: 0;
14 | overflow-y: scroll;
15 | }
16 | }
17 |
18 | .filters {
19 | max-width: 1280px;
20 | margin: 0 auto;
21 | padding: 20px;
22 | overflow: hidden;
23 | cursor: default;
24 | position: relative;
25 | @include border-box();
26 |
27 | &.modal {
28 | position: absolute;
29 | left: 0;
30 | right: 0;
31 | margin: 0 auto;
32 | background: #fff;
33 | top: 50%;
34 | transform: translateY(-50%);
35 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
36 | z-index: 101;
37 | }
38 |
39 | h3 {
40 | font: 18px 'MG Regular', sans-serif;
41 | margin-bottom: 8px;
42 | }
43 |
44 | label {
45 | cursor: pointer;
46 | }
47 |
48 | input[type="checkbox"] {
49 | position: absolute;
50 | left: -9999px;
51 | top: -9999px;
52 |
53 | & + label {
54 | position: relative;
55 | font: 18px 'MG Thin', sans-serif;
56 | padding-left: 20px;
57 | &:before {
58 | content: '';
59 | display: block;
60 | width: 11px;
61 | height: 11px;
62 | border: 1px solid #ddd;
63 | position: absolute;
64 | left: 0;
65 | bottom: 3px;
66 | }
67 | }
68 |
69 | &:checked + label:after {
70 | content: '';
71 | display: block;
72 | position: absolute;
73 | left: 0;
74 | bottom: 8px;
75 | width: 12px;
76 | height: 5px;
77 | border-left: 3px solid $PINK;
78 | border-bottom: 3px solid $PINK;
79 | transform: rotate(-45deg);
80 | border-radius: 2px;
81 | }
82 |
83 | }
84 |
85 | ul {
86 | list-style: none;
87 | overflow: hidden;
88 | padding-bottom: 10px;
89 | }
90 |
91 | li {
92 | width: 200px;
93 | float: left;
94 | padding-bottom: 4px;
95 | &.full-width {
96 | clear: both;
97 | width: 100%;
98 | }
99 | }
100 |
101 | .filters-section {
102 |
103 | &.sizes-filter {
104 | width: 25%;
105 | li {
106 | width: 100%;
107 | }
108 | }
109 | }
110 |
111 | .filters-foot {
112 | position: absolute;
113 | top: 20px;
114 | right: 20px;
115 |
116 | button {
117 | color: #fff;
118 | border-color: transparent;
119 | margin-left: 20px;
120 | background-color: $PINK;
121 | }
122 |
123 | }
124 |
125 | &.fancy-filters {
126 | li {
127 | width: 50%;
128 | }
129 | .filters-section {
130 | width: 75%;
131 | float: left;
132 | }
133 | }
134 |
135 | }
136 |
--------------------------------------------------------------------------------
/static/scss/filters.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/scss/mixins.scss:
--------------------------------------------------------------------------------
1 | $LIGHT_GREY: #eee;
2 | $GREY: #ddd;
3 | $DARK_GREY: #666;
4 | $PINK: #e94e77;
5 | $YELLOW: #ffed77;
6 | $DARK_YELLOW: #736400;
7 |
8 | // reddit colors
9 | $DOWN_VOTE: #a194ff;
10 | $UP_VOTE: #ff8b60;
11 | $NO_VOTE: #c6c6c6;
12 |
13 | $BASE_ARROW_SIZE: 8px;
14 |
15 | $NAV_INDEX: 100;
16 |
17 | @mixin image-text($background, $width, $height) {
18 | overflow: hidden;
19 | width: $width;
20 | height: 0;
21 | padding-top: $height;
22 | display: block;
23 | background: $background no-repeat;
24 | }
25 |
26 | @mixin hide-element() {
27 | position: absolute;
28 | left: -9999px;
29 | top: -9999px;
30 | }
31 |
32 | @mixin center-block() {
33 | margin: 0 auto;
34 | }
35 |
36 | @mixin border-box() {
37 | -moz-box-sizing: border-box;
38 | -webkit-box-sizing: border-box;
39 | box-sizing: border-box;
40 | }
41 |
42 | /**
43 | * Taken from: https://gist.github.com/thbar/1319313
44 | */
45 | @mixin gradient($from, $to) {
46 | /* fallback/image non-cover color */
47 | background-color: $from;
48 |
49 | /* Firefox 3.6+ */
50 | background-image: -moz-linear-gradient($from, $to);
51 |
52 | /* Safari 4+, Chrome 1+ */
53 | background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from($from), to($to));
54 |
55 | /* Safari 5.1+, Chrome 10+ */
56 | background-image: -webkit-linear-gradient($from, $to);
57 |
58 | /* Opera 11.10+ */
59 | background-image: -o-linear-gradient($from, $to);
60 |
61 | /* IE */
62 | background-image: -ms-linear-gradient(top, $from 0%, $to 100%);
63 | filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr="$from", endColorstr="$to")';
64 |
65 | }
66 |
67 | @mixin image-shadow() {
68 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
69 | }
70 |
71 | %image-label-base {
72 | font: 14px/1 'MG Regular', sans-serif;
73 | color: #000;
74 | display: block;
75 | background: #fff;
76 | position: absolute;
77 | padding: 10px 10px 8px;
78 | border-radius: 2px;
79 | @include image-shadow();
80 | z-index: 99;
81 | }
82 |
83 | @mixin image-label($label, $position: left) {
84 |
85 | @if $position == 'left' {
86 | &:before {
87 | @extend %image-label-base;
88 | content: $label;
89 | top: 10px;
90 | left: 10px;
91 | }
92 | } @else if $position == 'center' {
93 | &:before {
94 | @extend %image-label-base;
95 | content: $label;
96 | top: 50%;
97 | left: 50%;
98 | transform: translateX(-50%) translateY(-50%);
99 | }
100 | } @else if $position == 'right' {
101 | &:after {
102 | @extend %image-label-base;
103 | content: $label;
104 | top: 10px;
105 | right: 10px;
106 | }
107 | }
108 | }
109 |
110 | @mixin modal() {
111 | display: none;
112 | position: fixed;
113 | top: 20px;
114 | bottom: 20px;
115 | left: 20px;
116 | right: 20px;
117 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
118 | }
119 |
120 | @mixin fullscreen() {
121 | position: fixed;
122 | left: 0;
123 | right: 0;
124 | bottom: 0;
125 | top: 0;
126 | }
127 |
128 | @mixin transition($speed: 200ms) {
129 | -webkit-transition: all $speed linear;
130 | -moz-transition: all $speed linear;
131 | -o-transition: all $speed linear;
132 | -ms-transition: all $speed linear;
133 | transition: all $speed linear;
134 | }
--------------------------------------------------------------------------------
/static/scss/mobile.scss:
--------------------------------------------------------------------------------
1 | @import "mixins";
2 |
3 | @media screen and (max-width: 568px) {
4 | $HEADER_BG: #2c2c2c;
5 |
6 | body {
7 | padding-top: 50px;
8 | }
9 |
10 | header {
11 | background: $HEADER_BG;
12 | padding: 10px;
13 | border-bottom-color: #000;
14 | border-right: 0;
15 |
16 | h1 {
17 | font-size: 24px;
18 | position: static;
19 |
20 | @include transition(100ms);
21 |
22 | a {
23 | color: #fff;
24 | }
25 | }
26 |
27 | nav {
28 | @include fullscreen();
29 | @include transition(100ms);
30 | left: 100%;
31 | background: $HEADER_BG;
32 | padding: 10px;
33 | color: #fff;
34 | overflow: hidden;
35 | overflow-y: scroll;
36 |
37 | .primary-nav {
38 | margin: 0;
39 | & > li {
40 | display: block;
41 | padding: 5px;
42 | &.my-galleries {
43 | border-bottom: 1px solid #666;
44 | padding-bottom: 10px;
45 | margin-bottom: 5px;
46 | &:after {
47 | display: none;
48 | }
49 | }
50 | }
51 |
52 | h3,
53 | label,
54 | a {
55 | color: #fff !important;
56 | }
57 |
58 | .nav-login,
59 | .nav-upload,
60 | .nav-screensaver {
61 | display: none;
62 | }
63 |
64 | .has-secondary-nav {
65 | border: 0;
66 |
67 | &:hover {
68 | color: #fff;
69 | background: transparent;
70 | }
71 |
72 | &:before,
73 | &:after {
74 | content: none !important;
75 | display: none !important;
76 | }
77 |
78 | .secondary-nav {
79 | display: block;
80 | position: static;
81 | box-shadow: none;
82 | border: 0;
83 | padding: 10px 0 0;
84 | width: auto !important;
85 | background: none;
86 | }
87 |
88 | }
89 |
90 | }
91 | }
92 |
93 | .open-menu {
94 | display: block;
95 | position: absolute;
96 | padding: 10px;
97 | right: 0;
98 | top: 4px;
99 | border: 0;
100 | border-radius: 0;
101 | background: none;
102 |
103 | @include transition(100ms);
104 |
105 | .hamburger {
106 | display: block;
107 | height: 0;
108 | border-top: 3px solid #fff;
109 | border-bottom: 3px solid #fff;
110 | width: 24px;
111 | padding: 11px 0 0;
112 | overflow: hidden;
113 |
114 | &:before {
115 | content: '';
116 | display: block;
117 | width: 100%;
118 | height: 3px;
119 | background: #fff;
120 | position: relative;
121 | top: -7px;
122 | }
123 | }
124 |
125 | }
126 |
127 | .search {
128 | display: none;
129 | }
130 | }
131 |
132 | #content {
133 | @include transition(100ms);
134 | position: relative;
135 | left: 0;
136 | opacity: 1;
137 | }
138 |
139 | body.menu-open {
140 | h1 {
141 | margin-left: -200px;
142 | }
143 |
144 | nav {
145 | left: 44px;
146 | }
147 |
148 | .open-menu {
149 | right: 100%;
150 | margin-right: -44px;
151 | }
152 |
153 | #content {
154 | left: -75%;
155 | opacity: 0.25;
156 | }
157 |
158 | }
159 |
160 | #viewer {
161 | padding: 10px;
162 | .post-image {
163 | width: 100%;
164 | float: none;
165 | padding: 0;
166 | }
167 | .post-content {
168 | position: absolute;
169 | left: 0;
170 | right: 0;
171 | bottom: 0;
172 | width: 100%;
173 | float: none;
174 | padding: 10px;
175 | }
176 | }
177 |
178 | #upload {
179 | @include fullscreen();
180 |
181 | h2 {
182 | padding: 10px;
183 | background: $HEADER_BG;
184 | font-size: 24px;
185 | }
186 |
187 | .close {
188 | right: 5px;
189 | top: 5px;
190 | background: none;
191 | border: 0;
192 | color: #fff;
193 | }
194 |
195 | #imageUrl,
196 | #imageFile {
197 | width: 50%;
198 | @include border-box();
199 | float: left;
200 | }
201 |
202 | .uploadInput {
203 | overflow: hidden;
204 | }
205 |
206 | }
207 |
208 | .filters {
209 | background: transparent;
210 | width: auto;
211 | padding: 0 0 0 20px;
212 |
213 | h3 {
214 | display: none;
215 | }
216 |
217 | li {
218 | width: 100%;
219 | }
220 |
221 | .filters-foot {
222 | position: static;
223 | float: none;
224 |
225 | button {
226 | display: block;
227 | margin: 10px 0 0;
228 | }
229 |
230 | }
231 |
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/static/scss/upload.scss:
--------------------------------------------------------------------------------
1 | @import "mixins";
2 |
3 | #upload {
4 | @include modal();
5 | background: #fff;
6 | z-index: $NAV_INDEX + 1;
7 |
8 | opacity: 0;
9 | transform: translateY(30px) rotateX(30deg);
10 | -webkit-transition: transform 0.5s, opacity 0.5s;
11 | transition: transform 0.5s, opacity 0.5s;
12 | -moz-transition: none;
13 |
14 | &.open {
15 | opacity: 1;
16 | transform: translateY(0) rotateX(0);
17 | }
18 |
19 | h2 {
20 | padding: 20px;
21 | background: $PINK;
22 | color: #fff;
23 | font: 30px/1 'MG Thin', sans-serif;
24 | }
25 |
26 | .form {
27 | padding: 10px 20px;
28 | background: $GREY;
29 |
30 | fieldset {
31 | display: inline;
32 | border: 0;
33 | position: relative;
34 | }
35 |
36 | legend {
37 | display: none;
38 | }
39 |
40 | .finalize {
41 | padding-left: 20px;
42 | &:before {
43 | content: '';
44 | display: block;
45 | position: absolute;
46 | top: 5px;
47 | bottom: 5px;
48 | left: 8px;
49 | border-left: 1px solid $DARK_GREY;
50 | }
51 | }
52 |
53 | }
54 |
55 | .urlUpload {
56 | position: relative;
57 | padding-right: 25px;
58 | &:after {
59 | font: bold 24px sans-serif;
60 | content: '\00b7';
61 | display: block;
62 | position: absolute;
63 | right: 10px;
64 | top: -7px;
65 | color: $DARK_GREY;
66 | }
67 | }
68 |
69 | .repost {
70 | .actions {
71 | float: left;
72 | }
73 |
74 | p {
75 | font: 18px/1 'MG Thin', sans-serif;
76 | margin-bottom: 10px;
77 | }
78 |
79 | button {
80 | display: inline-block;
81 | margin-right: 10px;
82 | }
83 | }
84 |
85 | .uploads {
86 | list-style: none;
87 | position: absolute;
88 | left: 20px;
89 | right: 20px;
90 | top: 130px;
91 | bottom: 20px;
92 | overflow: scroll;
93 | overflow-x: hidden;
94 |
95 | > li {
96 | position: relative;
97 | padding: 20px 0;
98 | border-top: 1px dotted $GREY;
99 | overflow: hidden;
100 | &:first-child {
101 | border-top: 0;
102 | }
103 |
104 | &.uploading {
105 | font: 18px/1 'MG Thin', sans-serif;
106 | overflow: hidden;
107 | canvas {
108 | float: left;
109 | }
110 | }
111 |
112 | > img {
113 | @include image-shadow();
114 | float: left;
115 | margin-right: 20px;
116 | width: 150px;
117 | height: 150px;
118 | }
119 |
120 | }
121 |
122 | }
123 |
124 | .caption {
125 | display: block;
126 | margin-bottom: 10px;
127 | }
128 |
129 | .remove {
130 | display: block;
131 | border: 0;
132 | background: transparent;
133 | padding-left: 0;
134 | margin-top: 10px;
135 | color: $PINK;
136 | &:hover {
137 | text-decoration: underline;
138 | }
139 | }
140 |
141 | .close {
142 | position: absolute;
143 | top: 20px;
144 | right: 20px;
145 | font-size: 36px;
146 | padding: 0 10px;
147 | height: 34px;
148 | }
149 |
150 | .albumTitle {
151 | display: none;
152 | &.error {
153 | border-color: #f00;
154 | color: #f00;
155 | }
156 | }
157 |
158 | .similar-images {
159 | position: absolute;
160 | left: 560px;
161 | bottom: 20px;
162 | border-left: 2px solid $PINK;
163 | margin-left: 10px;
164 | padding-left: 20px;
165 | overflow: hidden;
166 |
167 | h3 {
168 | font: 18px 'MG Regular', sans-serif;
169 | margin-bottom: 8px;
170 | }
171 |
172 | li {
173 | display: inline-block;
174 | padding-right: 10px;
175 | position: relative;
176 | }
177 |
178 | span {
179 | position: absolute;
180 | bottom: 5px;
181 | left: 0;
182 | right: 10px;
183 | background: rgba(0, 0, 0, 0.75);
184 | padding: 5px 10px;
185 | font: 14px/1 'MG Thin';
186 | letter-spacing: 1px;
187 | color: #fff;
188 | }
189 |
190 | img {
191 | width: 75px;
192 | height: 75px;
193 | @include image-shadow();
194 | }
195 | }
196 |
197 | }
--------------------------------------------------------------------------------
/test/dal.php:
--------------------------------------------------------------------------------
1 | 'table_id',
13 | 'prop1' => 'table_prop1'
14 | ];
15 |
16 | public $id;
17 | public $prop1;
18 |
19 | }
20 |
21 | class DalTest extends PHPUnit_Framework_TestCase {
22 |
23 | public function testBasicQuery() {
24 |
25 | $result = DalObject::query();
26 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test`');
27 |
28 | }
29 |
30 | public function testQueryWithBasicCondition() {
31 | $result = DalObject::query([ 'id' => 5 ]);
32 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` WHERE `table_id` = :id');
33 | $this->assertEquals($result->params, [ ':id' => 5 ]);
34 |
35 | // error on invalid column
36 | $exception = false;
37 | try {
38 | $result = DalObject::query([ '; DROP TABLE `bobby`' => 'descending' ]);
39 | } catch (Exception $e) {
40 | $exception = true;
41 | }
42 | $this->assertTrue($exception);
43 |
44 | }
45 |
46 | public function testQueryWithSort() {
47 | // single sort
48 | $result = DalObject::query(null, [ 'id' => 'descending' ]);
49 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` ORDER BY `table_id` DESC');
50 |
51 | // Multi sort
52 | $result = DalObject::query(null, [ 'id' => 'ASC', 'prop1' => 'desc' ]);
53 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` ORDER BY `table_id` ASC, `table_prop1` DESC');
54 |
55 | // error on invalid column
56 | $exception = false;
57 | try {
58 | $result = DalObject::query(null, [ '; DROP TABLE `bobby`' => 'descending' ]);
59 | } catch (Exception $e) {
60 | $exception = true;
61 | }
62 | $this->assertTrue($exception);
63 |
64 | }
65 |
66 | public function testQueryWithLimit() {
67 | // without offset
68 | $result = DalObject::query(null, null, 5);
69 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` LIMIT 5');
70 |
71 | // throw out non-numeric limit
72 | $result = DalObject::query(null, null, '; DROP TABLE `bobby`');
73 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test`');
74 |
75 | // with offset
76 | $result = DalObject::query(null, null, 5, 5);
77 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` LIMIT 5, 5');
78 |
79 | // throw out non-numeric limit
80 | $result = DalObject::query(null, null, 5, '; DROP TABLE `bobby`');
81 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` LIMIT 5');
82 |
83 | }
84 |
85 | public function testQueryAll() {
86 | $result = DalObject::query([ 'id' => 5, 'prop1' => 'this thing' ], [ 'id' => 'desc' ], 5, 5);
87 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` WHERE `table_id` = :id AND `table_prop1` = :prop1 ORDER BY `table_id` DESC LIMIT 5, 5');
88 | $this->assertEquals($result->params, [ ':id' => 5, ':prop1' => 'this thing' ]);
89 | }
90 |
91 | public function testQueryIn() {
92 | $result = DalObject::query([ 'id' => [ 'in' => [ 1, 2, 3 ] ] ]);
93 | $this->assertEquals($result->query, 'SELECT `table_id`, `table_prop1` FROM `test` WHERE `table_id` IN (:id0, :id1, :id2)');
94 | $this->assertEquals($result->params, [ ':id0' => 1, ':id1' => 2, ':id2' => 3 ]);
95 | }
96 |
97 | }
--------------------------------------------------------------------------------
/test/harness.php:
--------------------------------------------------------------------------------
1 | assertEquals([ 'http://i.imgur.com/2dYfYlM.jpg' ], $this->getImagesFromUrl('http://i.imgur.com/2dYfYlM'));
14 | $this->assertEquals([ 'http://i.imgur.com/2dYfYlM.jpg' ], $this->getImagesFromUrl('http://i.imgur.com/2dYfYlM.jpg'));
15 |
16 | // Test imgur with no subdomain
17 | $this->assertEquals([ 'http://imgur.com/2dYfYlM.jpg' ], $this->getImagesFromUrl('http://imgur.com/2dYfYlM.jpg'));
18 | $this->assertEquals([ 'http://imgur.com/2dYfYlM.jpg' ], $this->getImagesFromUrl('http://imgur.com/2dYfYlM'));
19 |
20 | // Test with www domain
21 | $this->assertEquals([ 'http://www.imgur.com/2dYfYlM.jpg' ], $this->getImagesFromUrl('http://www.imgur.com/2dYfYlM'));
22 | }
23 |
24 | public function testImgurAlbum() {
25 |
26 | // Standard album
27 | $result = $this->getImagesFromUrl('http://imgur.com/a/hiwIT');
28 | $this->assertCount(13, $result);
29 |
30 | // Comma delimited list
31 | $result = $this->getImagesFromUrl('http://imgur.com/SnDVbyQ,eYARKqe');
32 | $this->assertCount(2, $result);
33 |
34 | }
35 |
36 | public function testMediaCrush() {
37 |
38 |
39 | // Test mediacru.sh
40 | $this->assertEquals([ 'https://mediacru.sh/xzoyUYowDuVc.png' ], $this->getImagesFromUrl('https://mediacru.sh/xzoyUYowDuVc'));
41 | }
42 |
43 | }
--------------------------------------------------------------------------------
/test/post.php:
--------------------------------------------------------------------------------
1 | assertEquals($base10Id, $id, 'HTTP with perma slug');
13 |
14 | $id = Api\Post::getPostIdFromUrl('https://redditbooru.com/gallery/' . $base36Id . '/my-gallery');
15 | $this->assertEquals($base10Id, $id, 'HTTPS with perma slug');
16 |
17 | $id = Api\Post::getPostIdFromUrl('https://redditbooru.com/gallery/' . $base36Id);
18 | $this->assertEquals($base10Id, $id, 'No gallery slug');
19 |
20 | $id = Api\Post::getPostIdFromUrl('https://awwnime.redditbooru.com/gallery/' . $base36Id);
21 | $this->assertEquals($base10Id, $id, 'Source subdomain');
22 | }
23 |
24 | }
--------------------------------------------------------------------------------
/test/postdata.php:
--------------------------------------------------------------------------------
1 | id = 1;
12 | $post->score = 500;
13 | $post->keywords = 'blah';
14 | $postData = new Api\PostData();
15 | $postData->postId = $post->id;
16 |
17 | $this->assertTrue($postData->updateAll($post));
18 | $result = Lib\Db::$lastResult;
19 |
20 | $this->assertEquals($result->query, 'UPDATE `post_data` SET `source_id` = :sourceId, `post_external_id` = :externalId, `post_date` = :dateCreated, `post_title` = :title, `user_id` = :userId, `post_keywords` = :keywords, `post_score` = :score, `post_nsfw` = :nsfw WHERE `post_id` = :postId');
21 | $this->assertEquals($result->params, [
22 | ':postId' => $post->id,
23 | ':userId' => $post->userId,
24 | ':sourceId' => $post->sourceId,
25 | ':externalId' => $post->externalId,
26 | ':dateCreated' => $post->dateCreated,
27 | ':title' => $post->title,
28 | ':keywords' => $post->keywords,
29 | ':score' => $post->score,
30 | ':nsfw' => $post->nsfw
31 | ]);
32 |
33 | }
34 |
35 | }
--------------------------------------------------------------------------------
/test/postimages.php:
--------------------------------------------------------------------------------
1 | id = 1;
12 | $img2 = new Api\Image();
13 | $img2->id = 3;
14 | $post = new Api\Post();
15 | $post->id = 1;
16 |
17 | $this->assertTrue(Api\PostImages::assignImagesToPost([ $img1, $img2 ], $post));
18 | $result = Lib\Db::$lastResult;
19 | $this->assertEquals($result->query, 'INSERT INTO post_images VALUES (:image0, :postId), (:image1, :postId)');
20 | $this->assertEquals($result->params, [ ':postId' => $post->id, ':image0' => $img1->id, ':image1' => $img2->id ]);
21 |
22 | }
23 |
24 | }
--------------------------------------------------------------------------------
/test/test_db.php:
--------------------------------------------------------------------------------
1 | query = $query;
21 | $retVal->params = $params;
22 | self::$lastResult = $retVal;
23 |
24 | // Action specific returns
25 | switch (strtolower(current(explode(' ', $query)))) {
26 | case 'insert':
27 | $retVal = 1;
28 | break;
29 | case 'update':
30 | case 'delete':
31 | $retVal = 1;
32 | break;
33 | }
34 |
35 | return $retVal;
36 | }
37 |
38 | public static function Fetch($resource) {
39 | return $resource;
40 | }
41 |
42 | }
43 |
44 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "sourceMap": true,
5 | "noImplicitAny": true,
6 | "module": "es6",
7 | "target": "es5",
8 | "allowJs": true
9 | }
10 | }
--------------------------------------------------------------------------------
/upload.php:
--------------------------------------------------------------------------------
1 | Curated by {{images.0.userName}}
3 | {{/if}}
4 | {{#each images}}
5 |
6 |

7 | {{#if caption}}
8 |
{{caption}}{{#if sourceUrl}} [ Source ]{{/if}}
9 | {{else}}
10 | {{#if sourceUrl}}
[ Source ]
{{/if}}
11 | {{/if}}
12 |
13 | {{/each}}
14 | {{#if wasResized}}
15 |
16 | {{/if}}
--------------------------------------------------------------------------------
/views/imageSearchDetails.hbs:
--------------------------------------------------------------------------------
1 |
2 |
Your Image
3 |

4 | {{#if identical}}
5 |
It looks like this image may already have been posted to
6 | {{#each identical}}
7 | {{.}}{{#sep}}, {{/sep}}
8 | {{/each}}
9 |
10 | {{/if}}
11 |
12 |
--------------------------------------------------------------------------------
/views/imageView.hbs:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 |
{{title}}
6 |
7 |
8 |
9 | {{score}}
10 |
11 |
12 |
Posted {{#if userName}}by {{userName}} {{/if}} {{#if sourceName}}to {{sourceName}}{{/if}} {{relativeTime dateCreated}} ago
13 | {{#if caption}}
14 |
{{caption}}
15 | {{/if}}
16 | {{#if externalId}}
17 |
View on reddit
18 | {{/if}}
19 | {{#if sourceUrl}}
20 |
Source
21 | {{/if}}
22 |
23 |
24 |
--------------------------------------------------------------------------------
/views/imagesRow.hbs:
--------------------------------------------------------------------------------
1 |
27 |
--------------------------------------------------------------------------------
/views/index.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/views/layouts/main.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{#if ogData}}
6 |
7 |
8 |
9 | {{/if}}
10 |
11 | {{#if pageTitle}}{{pageTitle}}{{else}}redditbooru - a place where cute girls come to meet{{/if}}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{>search}}
22 | {{>nav this}}
23 |
24 |
25 |
26 |
27 |
{{title}}
28 |
29 |
30 |
31 |
32 |
33 |
36 |
37 |
38 | {{>dragdrop}}
39 | {{>upload}}
40 | {{>globalUploader}}
41 | {{>imageViewer}}
42 |
43 |
52 |
53 | {{>ga}}
54 |
55 |
56 |
--------------------------------------------------------------------------------
/views/layouts/moesaic.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{title}}
5 |
18 |
19 |
20 |
21 |
22 |
27 |
28 |
29 |
30 |
171 |
174 |
175 |
--------------------------------------------------------------------------------
/views/layouts/no_sources.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{title}}
5 |
6 |
7 | Uh oh...
8 | It looks like you don't have any sources yet. Go into the database and add some!
9 |
10 |
--------------------------------------------------------------------------------
/views/layouts/upload.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/views/moreRow.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/views/partials/dragdrop.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/views/partials/filters.hbs:
--------------------------------------------------------------------------------
1 |
2 |
17 | {{#inTestBucket key="fancy-filters" value="enabled"}}
18 |
39 | {{/inTestBucket}}
40 |
45 |
--------------------------------------------------------------------------------
/views/partials/ga.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/views/partials/globalUploader.hbs:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/views/partials/imageViewer.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/views/partials/nav.hbs:
--------------------------------------------------------------------------------
1 |
67 |
68 |
--------------------------------------------------------------------------------
/views/partials/search.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
13 |
--------------------------------------------------------------------------------
/views/partials/upload.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/views/queryOptionItem.hbs:
--------------------------------------------------------------------------------
1 | {{#each items}}
2 |
3 |
7 |
8 | {{/each}}
--------------------------------------------------------------------------------
/views/uploadImageInfo.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{#inTestBucket key="sourceFinder" value="enabled"}}
9 |
10 | {{/inTestBucket}}
11 |
12 | {{! TODO - This needs to be a partial but browserify-handlebars doesn't support that currently }}
13 | {{#similar}}
14 |
15 |
Similar Images
16 |
17 | {{#results}}
18 | -
19 |
20 |
21 |
22 | {{sourceName}}
23 |
24 | {{/results}}
25 |
26 |
27 | {{/similar}}
28 |
--------------------------------------------------------------------------------
/views/uploadRepost.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 | It looks like this image has already been posted to {{#each identical}}{{.}}{{#sep}}, {{/sep}}{{/each}}. Would you like to add it anyways?
4 |
5 |
6 |
7 |
8 | {{! TODO - This needs to be a partial but browserify-handlebars doesn't support that currently }}
9 | {{#similar}}
10 |
11 |
Similar Images
12 |
13 | {{#results}}
14 | -
15 |
16 |
17 |
18 | {{sourceName}}
19 |
20 | {{/results}}
21 |
22 |
23 | {{/similar}}
24 |
--------------------------------------------------------------------------------
/views/uploading.hbs:
--------------------------------------------------------------------------------
1 |
2 |
3 | Retrieving {{url}}...
4 |
--------------------------------------------------------------------------------
/views/userGalleries.hbs:
--------------------------------------------------------------------------------
1 |
4 |
20 |
21 | {{#with paging}}
22 |
23 | {{#if hasPrev}}
24 | - « Previous
25 | {{/if}}
26 | {{#each pages}}
27 | {{#if current}}
28 | - {{page}}
29 | {{else}}
30 | - {{page}}
31 | {{/if}}
32 | {{/each}}
33 | {{#if hasNext}}
34 | - Next »
35 | {{/if}}
36 |
37 | {{/with}}
--------------------------------------------------------------------------------
/views/userProfile.hbs:
--------------------------------------------------------------------------------
1 |
2 |

3 |
{{name}}
4 |
Poster for {{relativeTime dateCreated}} with {{stats.totalPosts}} posts and {{stats.totalImages}} images
5 |
Contributes to
6 | {{#each postedOn}}
7 | {{#sep}}, {{/sep}}
8 | {{/each}}
9 |
10 |
11 |
Favorite Things to Post
12 |
13 |
14 |
15 | |
16 | Keywords |
17 | Times posted |
18 |
19 |
20 |
21 | {{#each favorites}}
22 |
23 |  |
24 | {{title}} |
25 | {{count}} posts |
26 |
27 | {{/each}}
28 |
29 |
30 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
3 |
4 | module.exports = {
5 | mode: 'development',
6 | entry: './static/js/App.js',
7 | devtool: 'inline-source-map',
8 | module: {
9 | rules: [
10 | {
11 | test: /\.ts$/,
12 | use: 'ts-loader',
13 | exclude: /node_modules/
14 | },
15 | {
16 | test: /\.hbs$/,
17 | use: 'handlebars-loader'
18 | },
19 | {
20 | test: /\.scss$/,
21 | use: [ MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader' ]
22 | }
23 | ]
24 | },
25 | resolve: {
26 | extensions: [ '.ts', '.js' ],
27 | alias: {
28 | '@views': path.resolve(__dirname, 'views')
29 | }
30 | },
31 | output: {
32 | filename: 'RedditBooru.js',
33 | path: path.resolve(__dirname, 'dist')
34 | },
35 | plugins: [
36 | new MiniCssExtractPlugin({
37 | filename: 'styles.css'
38 | })
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const baseConfig = require('./webpack.config');
2 |
3 | module.exports = {
4 | ...baseConfig,
5 | mode: 'production',
6 | devtool: 'source-map'
7 | };
8 |
--------------------------------------------------------------------------------