323 Commits
0.1.0 ... 0.7.0

Author SHA1 Message Date
66229e86be Version upgrade (0.7.0) 2014-03-02 17:19:48 +01:00
6d0ee4e03a Newest chibi-core 2014-03-02 17:18:58 +01:00
94412a25bb Fixed obscure search alias bug
When trying to search for hidden or disliked posts, it was impossible to search
by any aliases because of some hardcoded stuff. This commit removes the
hardcoded part altogether and fixes aliases support for these search terms.
2014-02-28 21:02:00 +01:00
426e104bbe Added special:fav search aliases
It displays favorites of user currently logged in.
2014-02-28 20:57:06 +01:00
fa251e60b6 Added :like and :dislike search aliases 2014-02-28 20:54:25 +01:00
34b9a80ba7 Moved Sql and Database.php to remote project 2014-02-28 20:44:35 +01:00
82b0d9a63a Newest chibi-core 2014-02-27 15:04:36 +01:00
06cdebaccb Fixed colors in tags pagination
Each page had recalculated tag opacity on its own. Now it's calculated against
global maximum.
2014-02-25 13:08:41 +01:00
c29a002c06 Fixes of previous commit... 2014-02-24 21:45:47 +01:00
cb489d1eca SQL operator refactor
* Added few new operators that were left hardcoded
* Changed "Operator" to "Functor"
* Better hierarchy - less mess
* Serialized SQL queries should contain fewer braces
2014-02-24 21:38:09 +01:00
a1378c98b4 Faster entity counting
All ORDER BY is discarded when counting entities in search services.
2014-02-24 16:50:16 +01:00
e725f8d554 Faster special:liked/disliked computing 2014-02-24 16:50:16 +01:00
e43881e03f Better debug 2014-02-24 16:50:16 +01:00
ff8bb761ee Added comment preloading 2014-02-24 16:50:16 +01:00
3a2a686b6c Faster preloading 2014-02-24 16:50:16 +01:00
e6b37afa8c Changed /comments behaviour
Instead of showing comments chronologically, group them into posts, then sort
the posts by last comment date. Reason: improved comment context delivery
makes discussion bumping possible (no matter how old it is) and discussion is
what comments are about.

Comment count is limited to 5 per post.
2014-02-24 16:50:16 +01:00
b144321c76 New Sql operators, because they may come in handy 2014-02-24 16:50:16 +01:00
ae09f20910 Fixed date: post search token 2014-02-24 16:50:16 +01:00
ec16073539 Fixes to SqlSelectStatement 2014-02-24 16:50:15 +01:00
0b10221fed Fixed small bugs in search services 2014-02-24 00:11:01 +01:00
2aefafa473 Favoriting a post automatically votes it up now
It's still possible for user to withdraw his vote afterwards for whatever
reason.
2014-02-23 22:46:51 +01:00
72946c3922 Fixed download and arrows transparency 2014-02-23 22:04:26 +01:00
975da67d33 Fixed tag list search styles
Search styles contained 'pending' option when staff was activation enabled
2014-02-23 22:04:26 +01:00
f2510ac8c0 Added focus color to comment links 2014-02-23 22:04:26 +01:00
4455284bdb Added a few search aliases
Each of "idmin", "datemax" etc got "id_min", "date_max" variant alias.
Additionally, "id" got new "ids" alias.
2014-02-23 22:04:26 +01:00
5827626deb Search services refactor
Code rerlated to search query parsing moved to separate classes.
2014-02-23 22:03:59 +01:00
4ce4ea6f70 More straightforward next/prev post calculation
Instead of getting all three rows at once using abs(id1-id2)<=1, it now asks DB
explicitly about id-1 and id+1. Even though it uses more SQL queries, it's
actually slightly faster.
2014-02-23 10:03:05 +01:00
a4fadb218b Fixed binding too many values to PDO statements 2014-02-23 10:00:21 +01:00
f59b92e06c Fixed showing hidden posts in /comments
If user has no privileges to list the hidden posts, comments on such posts
won't show in /comments anymore.
2014-02-23 09:27:50 +01:00
9eee8ba612 Mass tag: friendler pagination
If user is in mass tag mode and changes target tag but doesn't change the
query, he now remains at the same page. (Concerns only users who have disabled
endless scrolling.)
2014-02-22 23:51:25 +01:00
f783552820 Fixed appearance of editing flash and youtube posts 2014-02-22 23:37:48 +01:00
c0f52ecf28 Fixed HTML injection in some forms 2014-02-22 23:37:30 +01:00
395ac3033f Fixed HTML validation 2014-02-22 19:47:33 +01:00
6af3a0e42b SQL overhaul: introducing tree-like queries
Reason: until now, PostSearchService was using magic to get around the biggest
limitation of SqlQuery.php: it didn't support arbitrary order of operations.
You couldn't join with something and tell then to select something from it.
Additionally, forging UPDATE queries was a joke. The new Sql* classes replace
SqlQuery completely and address these issues. Using Sql* classes might be
tedious and ugly at times, but it is necessary step to improve model layer
maintainability.

It is by no menas complete implementation of SQL grammar, but for current needs
it's enough, and, what's most important, it is easily extensible.

Additional changes:
* Added sorting style aliases
  - fav_count
  - tag_count
  - comment_count
* Sorting by multiple tokens in post search is now possible
* Searching for disliked posts with "special:disliked" always yields results
  (even if user has disabled showing disliked posts by default)
* More maintainable next/prev post support
2014-02-22 19:40:10 +01:00
1baceb5816 Fixed tag pagination on endless scrolling 2014-02-21 20:24:37 +01:00
0b6a0337fe Exit confirmation tweaks in post upload
Confirmation is disabled after user removes last file from the upload queue.
It's enabled again whenever user adds something.
2014-02-21 20:24:37 +01:00
4b08686393 Added lightbox to post uploads 2014-02-21 20:24:37 +01:00
2bac28a553 More capable privilege system
Following privileges for post actions can now understand different settings for
everyone and for uploader:

* Scoring posts
* Featuring posts
* Flagging posts
* Favoriting posts

Additionally, privilege for flagging users can now understand different
settings for everyone and for the user that is currently logged in.

In other words: with this update admin can configure privileges so that scoring
own posts or flagging oneself will be prohibited, while scoring other people's
posts or flagging others will be okay.
2014-02-21 20:24:37 +01:00
28037af029 Registered users can mass tag their own posts 2014-02-21 20:24:37 +01:00
4420fa588d Post list errors are shown in nicer way 2014-02-21 20:24:37 +01:00
db8e13ec35 Merging and renaming tags yields status messages
Previously, it just redirected back to tag list without any kind of
notification about success.
2014-02-21 20:24:37 +01:00
1624fd5f63 Tag and user list: a-z order is case insensitive 2014-02-21 20:24:06 +01:00
705e3dfba1 Changed LOWER(?) to ? COLLATE NOCASE 2014-02-20 21:32:07 +01:00
dd498cf18d Fixed ban and unban confirmation messages 2014-02-20 21:32:07 +01:00
ddbecdb16f Fixed problems with multiple event handlers
Whenever DOM update event handlers were executed, jQuery bindings were appended
instead of being replaced. It resulted in funny scenarios like starting to show
multiple confirmation dialogs when trying to delete a post, after
adding/removing it from favorites.
2014-02-20 21:32:07 +01:00
b879a7c38b Fixed problem with comment edit links 2014-02-20 21:32:07 +01:00
38771eb7be Small layout fixes 2014-02-20 21:32:03 +01:00
b86aaf90a3 Fixed and simplified tag autocompletion 2014-02-18 21:26:54 +01:00
4469767d8f Removed console.log 2014-02-18 21:14:52 +01:00
43a33e579d Tweaks to unit converter 2014-02-18 18:35:58 +01:00
2bad17ebdb Fixed extension in saved posts 2014-02-18 18:35:58 +01:00
1352aba438 Fixed saving post original file name to DB 2014-02-18 18:35:58 +01:00
eee6421775 Post editing: quasi-popup in place of sliding unit 2014-02-18 18:35:55 +01:00
65c6caa13c Freshened up sidebar 2014-02-18 16:41:36 +01:00
e7a0fdae26 Fixed exit confirmation message on Chrome 2014-02-16 20:10:38 +01:00
f3a5de67e7 GUI colors are consistent 2014-02-16 20:10:38 +01:00
532fe9f7e6 Added pagination to tag list 2014-02-16 20:10:38 +01:00
18bfd6605d Searching: more robust entity counting 2014-02-16 20:10:38 +01:00
0c5fc7e03f Fixed useless arguments 2014-02-16 20:10:38 +01:00
3e99a6336c Form CSS overhaul 2014-02-16 20:10:34 +01:00
80b9542c2d Removed borders for sidebar units 2014-02-16 20:00:29 +01:00
4a69084a8b Upload no longer uses tabs 2014-02-16 20:00:26 +01:00
7a5d97e153 Dates changed to relative form (except logs) 2014-02-16 15:16:20 +01:00
e36498f709 Layout resizing tweaks 2014-02-16 15:16:17 +01:00
5148f9162d Changed tabs appearance 2014-02-16 12:33:52 +01:00
620d1204f7 Changed footer appearance 2014-02-16 12:33:52 +01:00
1a3f77175b Vertical scrollbar is shown everywhere
Reason: navigating between pages w/o scrollbar and pages with scrollbar
resulted in slight layout repositioning
2014-02-16 12:33:52 +01:00
db1d8383fd Fixed top margin in post upload 2014-02-16 12:33:52 +01:00
27c780602c Better looking user list 2014-02-16 12:33:52 +01:00
83a966f1af Added tab wrappers 2014-02-16 12:33:48 +01:00
8161bc9c88 Version upgrade (0.6.1) 2014-02-13 09:39:09 +01:00
c99596d12b Added last login date to users 2014-02-13 09:10:24 +01:00
b22e74c0e9 Closed #72 2014-02-05 08:35:24 +01:00
6a407fc87a Fixed #73 2014-02-05 08:22:26 +01:00
91b0432067 Fixed css
CSS for comments wasn't included in post-view.phtml. This manifested when user
posted a comment thruogh it (AJAX requests don't append CSS from AJAXed pages,
so added it in parent view).
2014-02-02 22:45:41 +01:00
f01f55cc8b Fixed #71 2014-02-02 22:44:13 +01:00
0b55dfad04 Markdown: restored ATX headers
See be3b39bf42.
It works again, but it requires putting a space after hash.
2014-02-02 19:23:52 +01:00
35cdc0cf3a Refactored scripts and stylesheets
Styles, scripts and page titles are no longer set from controllers level.
Changed because it was breaking MVC pattern and led to spaghetti code.

Also, optimized JS/CSS inclusions a bit.
2014-02-01 11:24:03 +01:00
d170e3b526 Closed #68 2014-02-01 11:23:59 +01:00
ac1997d4d0 Refactored search case sensitivity support 2014-02-01 09:54:46 +01:00
d085ffe39a Closed #70 2014-02-01 09:51:37 +01:00
d2946e0148 Post editing: fixed problem with focus 2014-01-30 21:53:34 +01:00
d01a087b30 Cosmetic changes 2014-01-27 09:21:52 +01:00
36e2e5827c Closed #69 2014-01-27 09:17:36 +01:00
752cbbd016 Fixed undefined method 2014-01-26 13:35:47 +01:00
37cc858821 Fixed post editing appearance 2014-01-25 22:50:15 +01:00
a869c1da1e Slightly changed comment edit log message 2014-01-25 16:44:37 +01:00
100303173e Added comment editing support
In other news, editing a post doesn't reload page anymore
(yay for editing tags for Youtube posts)
2014-01-25 16:39:09 +01:00
fd9433a2e3 Edit tokens moved to model 2014-01-25 15:09:20 +01:00
be3b39bf42 Markdown: disabled atx-style header support
Rationale - collision with tag syntax: if #tag was first word in given line,
the line was treated like header.
2014-01-25 14:09:56 +01:00
15486b6e9a Fixed problems with block-level spoilers
Block-level spoilers (= inside <h1>, <li> etc.) were left unparsed.
2014-01-14 23:20:47 +01:00
1fcced20f1 Misc CSS tweaks 2014-01-06 19:25:27 +01:00
56622b8e9d Last comments respect safety choice 2014-01-04 12:55:59 +01:00
4a9cc4b3bc Fixed invalid SQL in some circumstances 2014-01-04 12:55:03 +01:00
b1fb329fc7 Fixed silly bug
Statistics for each user in user list showed comment count instead of post
count.
2013-12-23 16:43:12 +01:00
306c6478b4 Micro optimalizations
Saved 0.015s on various things, mostly thanks to new chibi-core caching
2013-12-23 10:10:03 +01:00
8cfc2aeb2a Fixes for MySQL driver 2013-12-18 17:49:22 +01:00
9a9220ab24 Version upgrade (0.6.0) 2013-12-18 17:38:22 +01:00
6905ad047d Fixed changing access rank 2013-12-18 16:11:20 +01:00
5607cfc353 Models rewrite; removed RedBeanPHP; misc changes
Pages load 1.5-2x faster
Exception trace in JSON is now represented as an array
Fixed pagination of default favorites page in user pages
Fixed thumbnail size validation for non-square thumbnails
2013-12-18 15:17:49 +01:00
8c0c5269c4 Fixed 404 pages 2013-12-16 23:38:31 +01:00
95961fe7d5 Added tag sorting here and there
- Title attribute in post thumbnail
- Page title in post view
- Footer in featured post
2013-12-14 16:55:07 +01:00
1c6b10f966 Fixed 1px bug 2013-12-14 16:49:43 +01:00
5b25250209 Optimalizations 2013-12-14 14:50:30 +01:00
c7c5cde2b6 Fixed removing from favorites 2013-12-14 12:51:11 +01:00
5d45d6da2c Support for MySQL 2013-12-14 12:51:08 +01:00
31bc799518 Markdown: fixes related to <pre> blocks 2013-12-08 12:12:45 +01:00
7e8521022c Added CSS for quotes 2013-12-07 15:04:11 +01:00
9dcfd068df Improved general help tab title
"help" text was used twice in two navigation menus: once in top navigation and
once in tab bar (along with "prrivacy policy" and "rules"). The "help" in tab
bar was changed to "general help" to avoid potential confusion.
2013-12-05 23:57:35 +01:00
8f906d83bf Added active section indicator 2013-12-05 23:57:33 +01:00
b8e37a234a Better looking query debug 2013-12-05 22:22:11 +01:00
40e70c4305 User settings: new option to hide disliked posts 2013-12-05 22:21:15 +01:00
0d3bb32e9c Refactor to HTML structure
- <script> moved outside <ul>
- Youtube posts pass W3C validation
2013-12-01 15:16:10 +01:00
7046553a45 Fixed CSS problems with Chrome and Firefox 2013-12-01 15:15:01 +01:00
4c1bb44e59 Fixed rare bug in prev/next post
If tag/user/whatever from last search was deleted after viewing a post,
refreshing the page with that post would yield "Invalid tag/user/whatever"
error.

I changed it so that when retrieving previous/next post for latest search query
throws any errors, saved search query gets resetted to empty one.
2013-12-01 14:47:35 +01:00
0001d38699 Further tweaks to model
- Fixed broken negative searches
- Faster search by tag / comment / submit / favorites (useless nested joins
  replaced to entity prefetch). Side effect: searching for nonexistent tags,
  users etc yields informative errors instead of "no posts to show")
- Fixed duplicated column in order clause ("ORDER BY id DESC, id DESC")
2013-11-30 19:07:39 +01:00
992b9ba5ac Restored previous entity retrieval
Using temporary tables turned out to be more expensive on bigger databases.
Restoring two queries version.
2013-11-30 18:13:46 +01:00
e93c3588f9 Fix to tabs in upload 2013-11-30 16:42:47 +01:00
4285aff671 Fixes to preloading 2013-11-30 16:19:48 +01:00
31ccb9a281 Optimalization: changed entity retrieval 2013-11-30 14:23:53 +01:00
01c54d4d83 Optimalization: simplified selectors
Squash
2013-11-30 14:23:46 +01:00
dd8ab7c001 Optimalization: tweaks to .htaccess 2013-11-30 14:20:17 +01:00
d7cb024f24 Refactored pagination queries 2013-11-30 13:59:29 +01:00
28dbb85b46 Newest chibi-core 2013-11-30 01:10:58 +01:00
c9a8f99f6a Optimization: preloading moved back to controllers
- Nearly twice faster page load
- Query count greatly dropped
2013-11-30 01:10:58 +01:00
5a231b19c3 Bugfix to JS (unknown variable error) 2013-11-30 01:10:58 +01:00
3dd3ca5d99 Optimalization: moved <script> from HEAD to footer 2013-11-30 01:10:58 +01:00
1e954bb815 Optimalization: faster but dirty row retrieval 2013-11-30 01:10:55 +01:00
518311ff61 Optimalization: some JS tweaks
- Removed redundant function
- Using .preventDefault instead of return calls in some places
2013-11-30 01:10:48 +01:00
d570bc1790 Optimalization: sidebar options structure
- options rendering moved to separate file
- simplified template code
- removed redundant JS
2013-11-30 00:55:28 +01:00
5e58488f3e Optimalization: simplified tabs structure 2013-11-30 00:05:03 +01:00
e4b4c5d273 Optimalization: added stuff to .htaccess 2013-11-29 23:58:06 +01:00
83fa19ee22 Updated jQuery to 2.0.3; added jQuery map file 2013-11-29 23:58:06 +01:00
2a625db683 Added support for backets in tag names 2013-11-29 10:42:56 +01:00
4648b6afca Tag autocomplete aligned to right only in top nav 2013-11-27 17:44:52 +01:00
ef70c1523f HTML validation 2013-11-27 17:42:26 +01:00
89a1b1acf7 Version upgrade (0.5.0) 2013-11-26 18:18:54 +01:00
7820417188 Layout: fixed autocomplete box position 2013-11-25 22:23:48 +01:00
f226c3eb0c Fixed hotkeys conflicting with Flash on Chrome 2013-11-25 22:23:48 +01:00
c683fa3b0f User settings: added opt-in tags in post thumbs 2013-11-25 22:23:48 +01:00
505fe1bac3 Mass tag: fixed concurrent tag toggling 2013-11-25 22:23:48 +01:00
9819416f35 Markdown: fixed matching ( ) and { } in links 2013-11-25 22:23:48 +01:00
aa37ee66ff Various JS optimizations 2013-11-25 22:23:48 +01:00
20022ea4ab Next/prev links are bound to latest search query 2013-11-25 22:23:45 +01:00
d461a88001 Refactor to query builder; added triggers
Instead of recomputing comment/fav/tag count from scratch on every request,
store it in DB as *_count columns that get updated with proper triggers.
2013-11-24 21:41:38 +01:00
0ef5f1b46d Absolute paths used where necessary
- No random chdir() calls
- No more exceptions when executing scripts from dirs other than root
- find-posts.php prints absolute paths making piping more useful
2013-11-23 20:52:41 +01:00
75775cdc15 Increased letterbox threshold 2013-11-23 20:38:44 +01:00
0b22f2621e Markdown tweaks
- Faster log rendering
- New transform kind: simpleTransform()
2013-11-23 20:00:02 +01:00
c5292580ce Post edit: added custom thumbnail indicator 2013-11-23 17:29:08 +01:00
f8e19779a0 Cosmetic change to property model 2013-11-23 17:29:01 +01:00
3b532532d1 Post edit: post content can now be replaced 2013-11-23 17:27:56 +01:00
ee224b84db Tag list: added info when there are no tags 2013-11-23 16:03:43 +01:00
d274f1c044 Fixed featured post quirks
- fixes for empty database
- when post was deleted, new one is selected automatically
2013-11-23 16:03:40 +01:00
7e56fce470 Sending comment hides preview 2013-11-23 15:39:19 +01:00
fdd49d783a Compressed logs from uploads 2013-11-23 15:39:19 +01:00
18f7fff21f Uploading: empty files yield better error message 2013-11-23 15:39:19 +01:00
81017e18cb Logging: improved grammar a little 2013-11-23 15:39:19 +01:00
7fccbd5e02 JS: more robust error handling
Also, the upload form cannot be resent during processing
2013-11-23 15:39:19 +01:00
618f9e3d77 Any post type can be featured now
Automatic generator still searches only for images though.
2013-11-23 15:39:19 +01:00
676c3a41e2 Logging: removed logEvent() and log prefixes
This looked ugly.
2013-11-23 15:39:19 +01:00
db602f08d3 Better handling of anonymous user names 2013-11-23 15:39:19 +01:00
ecc72e52e6 Added margin for alerts 2013-11-23 15:39:19 +01:00
d8997edc57 Refactor of controllers and models
- Most of model-related code moved from controllers to model classes, much
  fewer calls to R::whatever() in controllers
- Post editing and uploading shares the same code, thus making implementing
  stuff easier in the future
- Added support for default bean wiring, no more calls to R::preload() all over
  the place
- More robust concurrent post editing detection
2013-11-23 15:39:13 +01:00
0a5169a7d6 JSON serializer: added exception info 2013-11-23 15:39:13 +01:00
c1e763316a Fav and comments are shown only if there are any 2013-11-23 15:39:13 +01:00
95b2eec461 Search queries: added search by likes/dislikes 2013-11-23 15:39:08 +01:00
6e9a18c0ae Paginator: introducing "..." pseudo-pages
If delta between pages in paginator is greater than 2, it adds "..." inbetween.
If delta is equal to 2, it adds missing page link instead.

Examples:

    1,4,5 gets converted to 1,...,4,5
    1,3,4 gets converted to 1,2,3,4
2013-11-23 13:54:13 +01:00
467f0c6b93 Slightly better tabs 2013-11-23 13:54:13 +01:00
0bbeb4604f Introducing tag list sort styles
Also, increased margin in /users
2013-11-23 13:54:13 +01:00
007e797d3a Faster tag list 2013-11-23 13:54:09 +01:00
d636fe1c0a Fixed hover 1px border bug 2013-11-22 00:26:05 +01:00
6e62a46110 Search queries: fixed broken order 2013-11-22 00:26:05 +01:00
9c0ed1e930 Markdown: fixed some [spoiler] bugs 2013-11-22 00:26:05 +01:00
909026ae0f User names: removed case sensitivity 2013-11-22 00:26:05 +01:00
c8fb9c20c6 Search queries: removed case sensitivity 2013-11-22 00:26:05 +01:00
6549237dda Mass tag: fixed tag case sensitiveness bug 2013-11-22 00:25:59 +01:00
601bdab8e1 Mass tag: don't show buttons if no tag specified 2013-11-21 22:44:28 +01:00
1f2ce725ff Mass tag redirection from /tags accepts empty tag
User was already able to enter masstag mode without specifying what they want
to tag with, through "mass tag" tab.
2013-11-21 22:44:28 +01:00
cab63895c2 Fixed "tag already exists" when only changing case 2013-11-21 22:44:28 +01:00
a98c61ebf3 Closed #67 2013-11-21 22:44:28 +01:00
fb5e851a13 Closed #66 2013-11-21 22:44:28 +01:00
77c0ea7f57 Markdown: better <br/> placement 2013-11-21 22:44:28 +01:00
6e229bf53c Markdown: fixed mini-issue mentioned in #66 2013-11-21 22:44:23 +01:00
5780917e82 Moved stuff to /data/
"Stuff" means:

- Config
- Local config
- SQLite db file
- Files
- Thumbnails
- Logs
2013-11-21 22:32:58 +01:00
a892410f5d Search queries: added new feature - "comment:x" 2013-11-21 22:32:49 +01:00
b2fdbb914e Fixed appearance on mobile 2013-11-19 20:20:16 +01:00
e336d04347 Logging minifix 2013-11-18 23:23:56 +01:00
aff68e88cf Version upgrade (0.4.1) 2013-11-18 18:26:51 +01:00
bf0e40683c Removed TextHelper hacks 2013-11-18 15:41:16 +01:00
17bd7a7572 Added support for OpenGraph
- Linking to index and individual posts produces thumbs on sites like Facebook
- Thumbnails theoretically support custom sizes
2013-11-18 14:33:43 +01:00
a5d0a3f9ef HTML validation 2013-11-18 14:00:54 +01:00
5eb5e18b77 Fixes to Markdown parsing introduced in 7605177 2013-11-18 11:22:29 +01:00
19a8b90ca2 Added unique indexes 2013-11-18 10:31:04 +01:00
e7ec8ea49f Fixed user view tabs 2013-11-18 10:30:43 +01:00
0286e11c30 Fixed dangling postscore and crossref rows 2013-11-18 10:26:29 +01:00
7605177a6b Added strike through support to Markdown 2013-11-18 00:38:33 +01:00
52ceb8d962 Fixes to CLI scripts 2013-11-18 00:16:47 +01:00
cc30829c63 Version upgrade (0.4.0) 2013-11-18 00:01:47 +01:00
9ab961985d Refactor to logging
- Centralized use of TextHelper::repr..() instead of hardcoded markdown
- Centralized processing of highlighting instead of hardcoded markdown
- Highlighted items are marked with color, not just bold
2013-11-17 23:46:31 +01:00
fdee23af99 Small changes
- Changed: rating posts - [up | down] --> [vote up, down]
- Fixed: logging of e-mail subject
- Improved: flagging posts/users provides visual feedback ("flagged")
- Improved: grammar in login screen
- Fixed: typo in password reset message
- Added: SessionHelper for handy management of user session data
2013-11-17 20:32:35 +01:00
3c41940142 Closed #57 2013-11-17 14:53:21 +01:00
da63c0fd19 Closed #61 2013-11-17 14:53:17 +01:00
4fd25b10c6 Fixed logging of post previews 2013-11-17 14:25:13 +01:00
210342a5bf Fixes to Markdown
- aa_bb cc_dd doesn't produce italics anymore
- asd@5.com doesn't produce link to post 5
- asd.com#anchor doesn't produce link to tag "anchor"
2013-11-17 14:25:05 +01:00
69a993c5af Fixed sending empty comments 2013-11-17 14:24:39 +01:00
7b473ba06f Low-level refactor to core.php 2013-11-17 14:24:39 +01:00
4166200dbc Post view actions don't reload the page anymore 2013-11-17 14:24:35 +01:00
4e64431a96 Changes to infobar in post thumbnails 2013-11-16 22:40:19 +01:00
6582b395d2 Added [P] hotkey for selecting first post on page 2013-11-16 22:02:18 +01:00
04e9bad79e Added logging engine for #61 2013-11-16 21:21:43 +01:00
45e9d32f58 Fixes to bugs introduced in 76a60ed 2013-11-16 21:14:27 +01:00
bb01ae7fca Closed #62 2013-11-16 19:24:50 +01:00
039d56c260 Further work on #62
Added ability to resend activation mail
2013-11-16 18:57:08 +01:00
76a60ed5d7 Refactoring of error/success messages 2013-11-16 18:44:40 +01:00
fb02feeed3 Preparation for #62 2013-11-16 17:32:43 +01:00
9ec269330c Dependancy extensions safety checks 2013-11-13 23:36:58 +01:00
8cd457848c Removed need for strict typing 2013-11-13 22:14:32 +01:00
70a4b46cf1 Foreign key fix 2013-11-13 19:54:36 +01:00
202c820a9a Closed #59 2013-11-13 19:44:36 +01:00
5e30253789 Closed #58 2013-11-13 19:42:22 +01:00
6fadc612fd Changed feature image style 2013-11-10 12:23:59 +01:00
7faf46beb9 Changed .ini a bit 2013-11-10 11:18:00 +01:00
7b014f036b Fixes to mass-tag 2013-11-06 00:51:16 +01:00
51dbc65754 Version upgrade (0.3.0) 2013-11-05 14:42:46 +01:00
b8fedc1297 Tags are sorted alphabetically 2013-11-05 13:56:20 +01:00
bb0e844e4e In case of misisng view file, render in JSON 2013-11-05 09:27:34 +01:00
09b5a38c95 Fixed issue with masstag in endless scrolling mode 2013-11-05 09:17:44 +01:00
b093a090eb Closed #56 2013-11-03 09:30:38 +01:00
e1c8139373 Unused tags are removed on post edit 2013-11-01 20:51:19 +01:00
101864459d Added safety check for tag renaming 2013-11-01 20:44:01 +01:00
f7a0b7b440 Focused tab is marked with different color 2013-11-01 15:41:56 +01:00
b3f15dc049 Header becomes less bloated in favor of tabs 2013-11-01 12:58:54 +01:00
be919603e3 Tag list gets tabbed interface 2013-11-01 12:58:48 +01:00
ac506e8c95 Added mass tag to header 2013-11-01 12:05:06 +01:00
8d5b82287a Fixed empty search queries in mass tag 2013-11-01 12:02:42 +01:00
f32c045349 Upload: increased thumbnail size to 150x150px 2013-11-01 10:38:32 +01:00
579df65c21 Increased source length limit to 200 chars 2013-11-01 10:37:35 +01:00
c4faa3bf85 Added benchmark helper 2013-11-01 10:08:43 +01:00
c3b2c68add Faster tag list 2013-11-01 10:08:35 +01:00
fe99f97287 Tag merging: fixed validation 2013-11-01 09:42:13 +01:00
bd05123cfc Post view: safety marked with color 2013-10-31 14:02:22 +01:00
9110a27167 Another safety switcher glitch fix 2013-10-30 23:33:18 +01:00
fd99821bd7 Logging in remembers original URL 2013-10-30 23:24:27 +01:00
ad8f2a8038 Changed thumb privilege (for weird configurations) 2013-10-30 23:04:50 +01:00
86c811b0e7 Added script for removing letterbox borders 2013-10-30 23:02:18 +01:00
ea4c7fac6e Added script for CLI post search
Other updates include removing unnecessary context retrieval and making post
query builder CLI friendly.
2013-10-30 22:47:33 +01:00
1714e9e665 Added support for post relations 2013-10-30 20:20:01 +01:00
157572d9ca Fixed post deletion
When post was deleted foreign keys in corresponding comments weren't NULLified.
2013-10-30 17:06:35 +01:00
19eea1e5b6 Login form: checkbox works when clicking text 2013-10-30 16:53:25 +01:00
b7084d61ae Closed #51 - anonymous uploads; simplified JS 2013-10-30 16:51:22 +01:00
36caef3831 Tag list respects safety settings 2013-10-30 16:22:46 +01:00
e0c4c28e70 Micro optimizations for tag list 2013-10-29 23:21:41 +01:00
96d994eeea CSS enhancements for focused elements 2013-10-29 23:01:02 +01:00
bc43883339 Closed #54 - added mass tag
- Moved tag forms to separate files
- Tag forms got tag autocompletion
2013-10-29 23:00:21 +01:00
cf1b5837a7 Reduced thumbnail size (PNG->JPG) 2013-10-29 09:27:02 +01:00
f119ab724a Paginator: A/D hotkeys disabled in endless mode 2013-10-28 13:01:49 +01:00
3130a66ad3 Fixed browsing settings 2013-10-28 12:58:18 +01:00
e2e9d9bf13 Fixed default safety 2013-10-28 11:26:45 +01:00
9e6716021a Models: enhanced entities filtering 2013-10-28 11:24:11 +01:00
49b91b7f55 Fixed (un)banning users (missing column in DB) 2013-10-27 23:23:48 +01:00
2aaafcd0de Updated help 2013-10-27 23:22:37 +01:00
24f5024db3 Fixed tag autocompletion
It yielded too many results in some cases.
2013-10-27 23:14:48 +01:00
e346a8e57c Added new search keywords
- tagmin, tagmax
- commentmin, commentmax
2013-10-27 23:02:15 +01:00
558f8f42c8 Closed #55 2013-10-27 22:55:14 +01:00
2f8d43cb4b Post edit link: focus the form on click 2013-10-27 22:47:20 +01:00
c4d5263422 Next/prev post navigation respects safety settings
Before change this setting was either ignored or errors were shown if users was
unable to view given post.
2013-10-27 20:51:03 +01:00
3f3024d6ac Added avatars for unknown users 2013-10-27 20:46:10 +01:00
b55a8f1dce Closed #52 - fixes for anonymous accounts
- Anonymous account is no longer created when commenting/uploading
- Anonymous users can now switch safety, if it's available
- Anonymous users can delete their own posts
- Refurbished session and logging in/out mechanism
- Possible fixes for registration/activation/account deletion issues
2013-10-27 20:39:32 +01:00
f726690ea3 Closed #53 2013-10-27 19:32:48 +01:00
0d360d525e SWF thumbnails: support for gnash
Swfrender produced mostly black squares. Gnash handles SWF files much, much
better than swfrender.
2013-10-27 19:27:25 +01:00
bddf04ea78 Hotkeys should no longer get in the way 2013-10-26 12:39:15 +02:00
d92d49d60d Posts: clickable source links; "unknown" if empty 2013-10-26 12:30:17 +02:00
35146e9587 Markdown: refurbished link parsing
- Added parsing of plain links in Markdown
- Linking with []() syntax should no longer produce relative links
2013-10-26 12:26:22 +02:00
cf749aa5fd Version upgrade (0.2.0) 2013-10-25 22:10:36 +02:00
0712f15ee4 Closed #50 2013-10-25 17:25:05 +02:00
db180376d4 Better help 2013-10-25 17:20:11 +02:00
0eb1ef4fff Added hotkey for next/prev page 2013-10-25 17:14:26 +02:00
5c76a41ae7 Fixed IE post list image alignment 2013-10-25 15:50:29 +02:00
0ea25dad24 Fixed IE border 2013-10-25 15:49:52 +02:00
c648cd848d Fixed hotkeys bug 2013-10-25 15:47:42 +02:00
c662d52d62 Closed #34
Introducing keyboard shortcut.
Every page:
[Q] - focus search
[W] - scroll up
[S] - scroll down
Post:
[A] - next post
[D] - previous post
[E] - edit post

Also, when clicking on post edit, browser is scrolled to the form.
2013-10-25 15:41:09 +02:00
e733da58d2 Removed background from images
Introducing it wasn't smart - I forgot about transparent PNGs
2013-10-25 15:40:32 +02:00
febf22a667 Various fixes
- Upload form: outlook
- Upload form: removed no files warning
- Upload form: fixed pasting empty text
- Forms: width of form elements
- Users: restored missing stylesheet
2013-10-25 14:57:04 +02:00
7d6bab9590 Fixed ce302c438d 2013-10-25 13:20:57 +02:00
2279e5605b Closed #37 2013-10-25 13:18:03 +02:00
4ecb3f3b81 Fixed updating safety settings 2013-10-25 11:55:03 +02:00
89826a0be9 Closed #49 2013-10-25 09:59:46 +02:00
d3eaf27bdc Closed #36 2013-10-25 09:59:42 +02:00
47759adb66 Closed #38 2013-10-24 16:41:41 +02:00
b5070e06fe Fixed thumbnail generating 2013-10-23 22:16:08 +02:00
e1acb8bd99 Reduced page loads
- Entity of user currently logged in is kept serialized in session
- Post is retrieved only if necessary in thumbnail generator
2013-10-23 00:16:52 +02:00
872780397d Fixed thumbnail cache
Custom thumbnails were loaded only after hard ctrl+f5. Now they should be
loaded with f5 alone.
2013-10-22 23:58:55 +02:00
d135f84bf2 Added paginator CSS to comments 2013-10-22 23:57:57 +02:00
31f07672c4 Improved #33 2013-10-22 23:57:53 +02:00
328d3f833b Fixed default user settings regarding safety 2013-10-22 21:56:27 +02:00
87eaa9ba9e Closed #33 2013-10-22 21:44:22 +02:00
18097b6192 Closed #45 2013-10-22 11:40:10 +02:00
739e5d3b5d Added uploader avatar 2013-10-22 09:24:17 +02:00
7cc2a98992 Post edit form moved to separate file 2013-10-22 00:39:41 +02:00
7f9aaad324 User settings DB column greatly compressed 2013-10-22 00:30:12 +02:00
319a9852fc Fixed deleting and (un)hiding 2013-10-22 00:20:58 +02:00
d45ab47d3b Always test your goddamn code 2013-10-22 00:18:41 +02:00
eaa8c4897d Closed #39 2013-10-22 00:17:40 +02:00
823888b0c1 Universal check for form submission 2013-10-22 00:17:36 +02:00
90a75e4d30 User edit/delete forms moved to separate files 2013-10-21 23:29:38 +02:00
ce302c438d Safety list in /upload is resolved automatically 2013-10-21 23:27:47 +02:00
70f931b921 Better checkboxes and radiobuttons 2013-10-21 23:25:56 +02:00
7743753641 Tag list visuals
- long tag text overflow in post-view and tag-list
- tag usage visualized in tag-list
2013-10-21 23:07:30 +02:00
e910d2f517 Smaller safety buttons 2013-10-21 15:16:34 +02:00
83355f3789 A bit more reasonable autocomplete (II) 2013-10-21 15:10:25 +02:00
9f5bdc3da0 A bit more reasonable autocomplete 2013-10-21 15:09:52 +02:00
0f72ef3963 Closed #48 2013-10-21 14:48:28 +02:00
6b55706fb4 Closed #46 2013-10-21 14:32:47 +02:00
ff3e4bc287 Closed #47 2013-10-21 14:24:34 +02:00
f2947a2550 Added "random" tab 2013-10-21 13:13:10 +02:00
aab67f4b6c Better main page 2013-10-21 09:35:06 +02:00
aed60da6f9 Changed post list view
- Thumbs are 150x150
- Centered main content
2013-10-20 19:51:49 +02:00
58a6345ae8 Fixed e-mail address visibility 2013-10-20 19:19:52 +02:00
bc24b7d2cf Fixed problems with Android keyboards
Users were completely unable to type anything.
2013-10-20 18:55:33 +02:00
3052a6f032 Disallowed . and .. as tag 2013-10-20 12:14:44 +02:00
4bfa2a019a Tags now allow dots 2013-10-20 12:08:52 +02:00
688385d553 Fixed /index link 2013-10-20 11:31:56 +02:00
a3be044ced First user doesn't see (unconfirmed) anymore 2013-10-20 11:19:59 +02:00
164 changed files with 8540 additions and 3172 deletions

6
.gitmodules vendored
View File

@ -4,6 +4,6 @@
[submodule "php-markdown"] [submodule "php-markdown"]
path = lib/php-markdown path = lib/php-markdown
url = https://github.com/michelf/php-markdown.git url = https://github.com/michelf/php-markdown.git
[submodule "redbean"] [submodule "lib/chibi-sql"]
path = lib/redbean path = lib/chibi-sql
url = https://github.com/gabordemooij/redbean.git url = https://github.com/rr-/chibi-sql.git

View File

@ -1,96 +0,0 @@
[chibi]
userCodeDir=./src/
prettyPrint=1
[main]
dbPath=./db.sqlite
filesPath=./files/
thumbsPath=./thumbs/
mediaPath=./public_html/media/
title=szurubooru
featuredPostMaxDays=7
debugQueries=0
[browsing]
usersPerPage=8
postsPerPage=20
thumbWidth=140
thumbHeight=140
thumbStyle=outside
endlessScrolling=1
maxSearchTokens=4
[comments]
minLength = 5
maxLength = 2000
commentsPerPage = 20
[registration]
staffActivation = 0
passMinLength = 5
passRegex = "/^.+$/"
userNameMinLength = 3
userNameMaxLength = 20
userNameRegex = "/^[\w_-]+$/ui"
salt = "1A2/$_4xVa"
needEmailForRegistering = 1
needEmailForCommenting = 0
needEmailForUploading = 1
confirmationEmailEnabled = 1
confirmationEmailSenderName = "{host} registration engine"
confirmationEmailSenderEmail = "noreply@{host}"
confirmationEmailSubject = "{host} activation"
confirmationEmailBody = "Hello,
You received this e-mail because someone registered a user with this address at {host}. If it's you, visit {link} to finish registration process, otherwise you may ignore and delete this e-mail.
Kind regards,
{host} registration engine"
[privileges]
uploadPost=registered
listPosts=anonymous
listPosts.sketchy=registered
listPosts.unsafe=registered
listPosts.hidden=nobody
viewPost=anonymous
viewPost.sketchy=registered
viewPost.unsafe=registered
viewPost.hidden=admin
retrievePost=anonymous
favoritePost=registered
editPostSafety.own=registered
editPostSafety.all=moderator
editPostTags=registered
editPostThumb=moderator
editPostSource=moderator
hidePost.own=moderator
hidePost.all=moderator
deletePost.own=moderator
deletePost.all=moderator
featurePost=moderator
listUsers=registered
viewUser=registered
viewUserEmail=admin
changeUserPassword.own=registered
changeUserPassword.all=admin
changeUserEmail.own=registered
changeUserEmail.all=admin
changeUserAccessRank=admin
changeUserName=moderator
acceptUserRegistration=moderator
banUser.own=nobody
banUser.all=admin
deleteUser.own=registered
deleteUser.all=nobody
listComments=anonymous
addComment=registered
deleteComment.own=registered
deleteComment.all=moderator
listTags=anonymous
mergeTags=moderator
renameTags=moderator

View File

130
data/config.ini Normal file
View File

@ -0,0 +1,130 @@
[chibi]
enableCache=1
[main]
dbDriver = "sqlite"
dbLocation = "./data/db.sqlite"
dbUser = "test"
dbPass = "test"
filesPath = "./data/files/"
thumbsPath = "./data/thumbs/"
logsPath = "./data/logs/"
mediaPath = "./public_html/media/"
title = "szurubooru"
salt = "1A2/$_4xVa"
[misc]
featuredPostMaxDays=7
debugQueries=0
logAnonymousUploads=1
[help]
title=Help
subTitles[help]=General help
subTitles[rules]=Rules
subTitles[privacy]=Privacy policy
paths[help]=./data/help.md
paths[rules]=./data/rules.md
paths[privacy]=./data/privacy.md
[browsing]
usersPerPage=8
postsPerPage=20
logsPerPage=250
tagsPerPage=100
thumbWidth=150
thumbHeight=150
thumbStyle=outside
endlessScrollingDefault=1
showPostTagTitlesDefault=0
showDislikedPostsDefault=1
maxSearchTokens=4
maxRelatedPosts=50
[comments]
minLength = 5
maxLength = 2000
commentsPerPage = 10
maxCommentsInList = 5
[registration]
staffActivation = 0
passMinLength = 5
passRegex = "/^.+$/"
userNameMinLength = 3
userNameMaxLength = 20
userNameRegex = "/^[\w_-]+$/ui"
needEmailForRegistering = 1
needEmailForCommenting = 0
needEmailForUploading = 1
confirmationEmailEnabled = 1
confirmationEmailSenderName = "{host} mailing system"
confirmationEmailSenderEmail = "noreply@{host}"
confirmationEmailSubject = "{host} - account activation"
confirmationEmailBody = "Hello,{nl}{nl}You received this e-mail because someone registered a user with this e-mail address at {host}. If it's you, visit {link} to finish registration process, otherwise you may ignore and delete this e-mail.{nl}{nl}Kind regards,{nl}{host} mailing system"
passwordResetEmailSenderName = "{host} mailing system"
passwordResetEmailSenderEmail = "noreply@{host}"
passwordResetEmailSubject = "{host} - password reset"
passwordResetEmailBody = "Hello,{nl}{nl}You received this e-mail because someone requested a password reset for user with this e-mail address at {host}. If it's you, visit {link} to finish password reset process, otherwise you may ignore and delete this e-mail.{nl}{nl}Kind regards,{nl}{host} mailing system"
[privileges]
uploadPost=registered
listPosts=anonymous
listPosts.sketchy=registered
listPosts.unsafe=registered
listPosts.hidden=admin
viewPost=anonymous
viewPost.sketchy=registered
viewPost.unsafe=registered
viewPost.hidden=admin
retrievePost=anonymous
favoritePost=registered
editPostSafety.own=registered
editPostSafety.all=moderator
editPostTags=registered
editPostThumb=moderator
editPostSource=moderator
editPostRelations.own=registered
editPostRelations.all=moderator
editPostFile=moderator
massTag.own=registered
massTag.all=power-user
hidePost=moderator
deletePost=moderator
featurePost=moderator
scorePost=registered
flagPost=registered
listUsers=registered
viewUser=registered
viewUserEmail.all=admin
viewUserEmail.own=registered
changeUserPassword.own=registered
changeUserPassword.all=admin
changeUserEmail.own=registered
changeUserEmail.all=admin
changeUserAccessRank=admin
changeUserName=moderator
changeUserSettings.all=nobody
changeUserSettings.own=registered
acceptUserRegistration=moderator
banUser.own=nobody
banUser.all=admin
deleteUser.own=registered
deleteUser.all=nobody
flagUser=registered
listComments=anonymous
addComment=registered
deleteComment.own=registered
deleteComment.all=moderator
editComment.own=registered
editComment.all=admin
listTags=anonymous
mergeTags=moderator
renameTags=moderator
listLogs=moderator
viewLog=moderator

64
data/help.md Normal file
View File

@ -0,0 +1,64 @@
# Browsing
Clicking the Browse button at the top will take you to the list of recent posts. Use the search box in the top right corner to find posts you want to see.
If you&rsquo;re not a registered user, you will only see public (Safe) posts. Logging in to your account will enable you to filter content by its rating: Safe, Sketchy, and NSFW.
You can use your keyboard to navigate around the site. There are a few shortcuts:
- focus search field: `[Q]`
- scroll up/down: `[W]` and `[S]`
- go to newer/older post or page: `[A]` and `[D]`
- edit post: `[E]`
- focus first post in post list: `[P]`
# Search syntax
- contatining tag "Haruhi": [search]Haruhi[/search]
- **not** contatining tag "Kyon": [search]-Kyon[/search]
- uploaded by David: [search]submit:David[/search] (note no spaces)
- favorited by David: [search]fav:David[/search]
- favorited by at least four users: [search]favmin:4[/search]
- commented by David: [search]comment:David[/search]
- having at least three comments: [search]commentmin:3[/search]
- having minimum score of 4: [search]scoremin:4[/search]
- tagged with at least seven tags: [search]tagmin:7[/search]
- exactly from the specified date: [search]date:2001[/search], [search]date:2012-09-29[/search] (yyyy-mm-dd format)
- from the specified date onwards: [search]datemin:2001-01-01[/search]
- up to the specified date: [search]datemax:2004-07[/search]
- having specific ID: [search]id:1,2,3,8[/search]
- having ID no less than specified value: [search]idmin:28[/search]
- by content type: [search]type:img[/search], [search]type:swf[/search], [search]type:yt[/search] (images, flash files and YouTube videos, respectively)
- scored up/down by currently logged in user: [search]special:likes[/search] and [search]special:dislikes[/search]
You can combine tags and negate any of them for interesting results. [search]sea -favmin:8 type:swf submit:Pirate[/search] will show you **flash files** tagged as **sea**, that were **liked by seven people** at most, uploaded by user **Pirate**.
All of the above can be sorted using additional sorting tags:
- as random as it can get: [search]order:random[/search]
- newest to oldest: [search]order:date[/search] (pretty much default browse view)
- oldest to newest: [search]-order:date[/search]
- most commented first: [search]order:comments[/search]
- loved by most: [search]order:favs[/search]
- highest scored: [search]order:score[/search]
- with most tags: [search]order:tags[/search]
As shown with [search]-order:date[/search], any of them can be reversed in the same way as negating other tags: by placing a dash before the tag. If there is a "min" tag, there&rsquo;s also its "max" counterpart, e.g. [search]favmax:7[/search].
# Registration
The e-mail you enter during account creation is only used to retrieve your [Gravatar](http://gravatar.com) and activate your account. Only you can see it (well, except the database staff&hellip; we won&rsquo;t spam your mailbox anyway).
Oh, and you can delete your account at any time. Posts you uploaded will stay, unless some angry admin removes them.
# Comments
Registered users can post comments. Comments support [Markdown syntax](http://daringfireball.net/projects/markdown/syntax), extended by some handy tags:
- permalink to post number 426: @426
- link to tag "Dragon_Ball": #Dragon_Ball
- mark text as spoiler and hide it: [spoiler]&#91;spoiler]There is no spoon.&#91;/spoiler][/spoiler]
# Uploads
After registering and activating your account, you gain the power to upload files to the service for everyone else to see.

11
data/privacy.md Normal file
View File

@ -0,0 +1,11 @@
# Stored information
Posts, comments, favorites and ratings linked to your account will be stored in the site&rsquo;s database and publicly available.
The "Upload anonymously" option allows you to post content without linking it to your account - meaning your nickname will not be stored in the database nor shown in the "Uploader" field.
Your actions related to posts (uploading, tagging, etc.) are logged, along with your nickname and IP.
# Cookies
Cookies are used to store your session data and browsing preferences, such as endless scrolling or visibility of NSFW posts.

8
data/rules.md Normal file
View File

@ -0,0 +1,8 @@
# Site rules
- Don&rsquo;t spam, [troll](http://www.urbandictionary.com/define.php?term=troll) or offend anyone. Be nice.
- You are not allowed to post any form of [cp](http://www.urbandictionary.com/define.php?term=cp). If you possess it, we ask you to leave immediately and never come back.
- Don&rsquo;t use the site to host your personal images (e.g. forum avatars).
- This kind of content is not welcome and will be removed.
- If you notice such content, please flag it for moderation.
- Owners of the site are not responsible for content uploaded by users.

2
data/thumbs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -1,8 +1,8 @@
<?php <?php
require_once 'src/core.php'; require_once 'src/core.php';
$config = configFactory(); $config = \Chibi\Registry::getConfig();
$fontsPath = $config->main->mediaPath . DS . 'fonts' . DS; $fontsPath = TextHelper::absolutePath($config->main->mediaPath . DS . 'fonts');
$libPath = $config->main->mediaPath . DS . 'lib' . DS; $libPath = TextHelper::absolutePath($config->main->mediaPath . DS . 'lib');
@ -29,10 +29,11 @@ function download($source, $destination = null)
//jQuery //jQuery
download('http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js', $libPath . 'jquery' . DS . 'jquery.min.js'); download('http://code.jquery.com/jquery-2.0.3.min.js', $libPath . DS . 'jquery' . DS . 'jquery.min.js');
download('http://code.jquery.com/jquery-2.0.3.min.map', $libPath . DS . 'jquery' . DS . 'jquery.min.map');
//jQuery UI //jQuery UI
download('http://ajax.googleapis.com/ajax/libs/jqueryui/1.10.3/jquery-ui.min.js', $libPath . 'jquery-ui' . DS . 'jquery-ui.min.js'); download('http://ajax.googleapis.com/ajax/libs/jqueryui/1.10.3/jquery-ui.min.js', $libPath . DS . 'jquery-ui' . DS . 'jquery-ui.min.js');
$manifest = download('http://ajax.googleapis.com/ajax/libs/jqueryui/1/MANIFEST'); $manifest = download('http://ajax.googleapis.com/ajax/libs/jqueryui/1/MANIFEST');
$lines = explode("\n", str_replace("\r", '', $manifest)); $lines = explode("\n", str_replace("\r", '', $manifest));
foreach ($lines as $line) foreach ($lines as $line)
@ -40,18 +41,21 @@ foreach ($lines as $line)
if (preg_match('/themes\/flick\/(.*?) /', $line, $matches)) if (preg_match('/themes\/flick\/(.*?) /', $line, $matches))
{ {
$srcUrl = 'http://ajax.googleapis.com/ajax/libs/jqueryui/1/' . $matches[0]; $srcUrl = 'http://ajax.googleapis.com/ajax/libs/jqueryui/1/' . $matches[0];
$dstUrl = $libPath . 'jquery-ui' . DS . $matches[1]; $dstUrl = $libPath . DS . 'jquery-ui' . DS . $matches[1];
download($srcUrl, $dstUrl); download($srcUrl, $dstUrl);
} }
} }
//jQuery Tag-it! //jQuery Tag-it!
download('http://raw.github.com/aehlke/tag-it/master/css/jquery.tagit.css', $libPath . 'tagit' . DS . 'jquery.tagit.css'); download('http://raw.github.com/aehlke/tag-it/master/css/jquery.tagit.css', $libPath . DS . 'tagit' . DS . 'jquery.tagit.css');
download('http://raw.github.com/aehlke/tag-it/master/js/tag-it.min.js', $libPath . 'tagit' . DS . 'jquery.tagit.js'); download('http://raw.github.com/aehlke/tag-it/master/js/tag-it.min.js', $libPath . DS . 'tagit' . DS . 'jquery.tagit.js');
//Mousetrap
download('http://raw.github.com/ccampbell/mousetrap/master/mousetrap.min.js', $libPath . DS . 'mousetrap' . DS . 'mousetrap.min.js');
//fonts //fonts
download('http://googlefontdirectory.googlecode.com/hg/apache/droidsans/DroidSans.ttf', $fontsPath . 'DroidSans.ttf'); download('http://googlefontdirectory.googlecode.com/hg/apache/droidsans/DroidSans.ttf', $fontsPath . DS . 'DroidSans.ttf');
download('http://googlefontdirectory.googlecode.com/hg/apache/droidsans/DroidSans-Bold.ttf', $fontsPath . 'DroidSans-Bold.ttf'); download('http://googlefontdirectory.googlecode.com/hg/apache/droidsans/DroidSans-Bold.ttf', $fontsPath . DS . 'DroidSans-Bold.ttf');

1
lib/chibi-sql Submodule

Submodule lib/chibi-sql added at a5d7a03965

Submodule lib/redbean deleted from 95cf7d231b

View File

@ -10,3 +10,40 @@ RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.*$ /dispatch.php RewriteRule ^.*$ /dispatch.php
RewriteRule ^/?$ /dispatch.php RewriteRule ^/?$ /dispatch.php
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/javascript
AddOutputFilterByType DEFLATE text/plain
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE text/xml
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/xml
AddOutputFilterByType DEFLATE application/xhtml+xml
AddOutputFilterByType DEFLATE application/rss+xml
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE application/x-javascript
</IfModule>
<IfModule mod_mime.c>
AddType text/plain .map
</IfModule>
<IfModule mod_expires.c>
ExpiresActive on
ExpiresByType image/jpg "access plus 2 years"
ExpiresByType image/gif "access plus 2 years"
ExpiresByType image/jpg "access plus 2 years"
ExpiresByType image/jpeg "access plus 2 years"
ExpiresByType image/png "access plus 2 years"
ExpiresByType image/x-icon "access plus 2 years"
ExpiresByType image/icon "access plus 2 years"
ExpiresByType application/x-ico "access plus 2 years"
ExpiresByType application/ico "access plus 2 years"
ExpiresByType text/plain "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType text/javascript "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType application/x-javascript "access plus 1 year"
</IfModule>

View File

@ -1,7 +1,5 @@
<?php <?php
chdir('..'); require_once '../src/core.php';
require_once 'src/core.php';
require_once 'src/Bootstrap.php';
$query = $_SERVER['REQUEST_URI']; $query = $_SERVER['REQUEST_URI'];
\Chibi\Facade::run($query, configFactory(), new Bootstrap()); \Chibi\Facade::run($query, new Bootstrap());

View File

@ -1,13 +1,30 @@
form.auth { #content form {
margin: 0 auto; margin: 0 auto;
max-width: 400px; max-width: 400px;
} }
form.auth label.left { #content form label {
width: 35%; width: 35%;
} }
form.auth p { #content form p {
text-align: center; text-align: center;
margin: 10px 0; margin: 10px 0;
} }
#content form .help {
opacity: .5;
margin-top: 1em;
font-size: small;
}
#content form .help p {
margin: 0;
text-align: left;
}
#content form .help label+div {
float: left;
}
#content form .help ul {
margin: 0;
padding: 0;
}

View File

@ -0,0 +1,12 @@
.preview {
border: 1px solid yellow;
background: url('../img/preview.png') lemonchiffon;
padding: 0.5em;
display: none;
}
form.edit-comment textarea,
form.add-comment textarea {
width: 50em;
height: 8em;
}

View File

@ -26,3 +26,8 @@
.small-screen .comment-group .comments { .small-screen .comment-group .comments {
margin-left: 0; margin-left: 0;
} }
.hellip {
margin-bottom: 2em;
display: inline-block;
}

View File

@ -22,27 +22,35 @@
.comment { .comment {
clear: left; clear: left;
} }
.comment .date:before {
content: ' on ';
margin: 0 0.2em;
}
.comment .date { .comment .date {
margin: 0 0.2em 0 0.75em;
color: silver; color: silver;
} }
.comment .date, .comment .date,
.comment .edit,
.comment .delete { .comment .delete {
font-size: small; font-size: small;
} }
.comment .edit:before,
.comment .delete:before { .comment .delete:before {
margin-left: 0.2em; margin-left: 0.2em;
content: ' ['; content: ' [';
color: silver; color: silver;
} }
.comment .edit:after,
.comment .delete:after { .comment .delete:after {
content: ']'; content: ']';
color: silver; color: silver;
} }
.comment .edit a,
.comment .delete a { .comment .delete a {
color: silver; color: silver;
} }
.comment .edit a:hover,
.comment .delete a:hover,
.comment .edit a:focus,
.comment .delete a:focus {
color: red;
}

View File

@ -17,6 +17,8 @@ body {
color: black; color: black;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: auto;
overflow-y: scroll;
font-family: 'Droid Sans', sans-serif; font-family: 'Droid Sans', sans-serif;
font-size: 12pt; font-size: 12pt;
} }
@ -33,11 +35,15 @@ body {
} }
.main-wrapper { .main-wrapper {
margin: 0 1.5em; margin: 0 auto;
padding: 0 30px;
} }
#top-nav .clear {
background: white;
}
#top-nav ul.main-nav { #top-nav ul.main-nav {
margin: 0 0 0 -0.75em; margin: 0 0 0 -0.75em;
padding: 0; padding: 0;
@ -45,6 +51,12 @@ body {
} }
#top-nav li.main-nav-item { #top-nav li.main-nav-item {
display: inline-block; display: inline-block;
float: left;
}
#top-nav li.main-nav-item.active a {
border-bottom: 3px solid #f7f7f7;
margin-bottom: 0;
background: #f7f7f7;
} }
#top-nav li input, #top-nav li input,
@ -61,12 +73,13 @@ body {
} }
#top-nav li.main-nav-item a:focus, #top-nav li.main-nav-item a:focus,
#top-nav li.main-nav-item a:hover { #top-nav li.main-nav-item a:hover {
color: firebrick; color: hsl(0,70%,45%);
border-bottom: 3px solid firebrick; border-bottom: 3px solid hsl(0,70%,45%);
margin-bottom: 0; margin-bottom: 0;
} }
#top-nav li.search { #top-nav li.search {
display: inline-block;
float: right; float: right;
background: white; background: white;
margin: 5px 0; margin: 5px 0;
@ -75,14 +88,14 @@ body {
} }
#top-nav li.search input { #top-nav li.search input {
border: 0; border: 0;
height: 20px; height: 28px;
line-height: 20px; line-height: 28px;
padding: 4px 10px; padding: 0 10px;
margin: 0; margin: 0;
box-sizing: content-box;
} }
#top-nav li.safety { #top-nav li.safety {
display: inline-block;
float: right; float: right;
margin-left: 5px; margin-left: 5px;
} }
@ -98,8 +111,8 @@ body {
display: inline-block; display: inline-block;
float: left; float: left;
width: 25px; width: 25px;
line-height: 38px; line-height: 28px;
margin-right: -1px; margin: 5px -1px 5px 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
} }
#top-nav li.safety a:after { #top-nav li.safety a:after {
@ -108,6 +121,7 @@ body {
#top-nav li.safety span { #top-nav li.safety span {
display: none; display: none;
} }
#top-nav li.safety a:focus,
#top-nav li.safety a:hover { opacity: .7; } #top-nav li.safety a:hover { opacity: .7; }
#top-nav li.safety a.inactive { opacity: 1; } #top-nav li.safety a.inactive { opacity: 1; }
#top-nav li.safety .safety-safe .enabled { background: #cfe6c2; background: linear-gradient(to bottom, #CFE6C2 0%, #80C670 100%); } #top-nav li.safety .safety-safe .enabled { background: #cfe6c2; background: linear-gradient(to bottom, #CFE6C2 0%, #80C670 100%); }
@ -119,11 +133,9 @@ body {
footer { footer .main-wrapper {
text-align: center; text-align: center;
margin: 1em 0; margin-top: 1em;
padding-top: 0.5em;
border-top: 1px solid #eee;
font-size: small; font-size: small;
color: silver; color: silver;
} }
@ -139,11 +151,16 @@ footer a {
#sidebar { #sidebar {
float: left; float: left;
width: 256px; width: 240px;
margin-right: 2em; margin-right: 15px;
} }
#sidebar h1 { #sidebar h1 {
margin-top: 0; margin-top: 0;
margin-bottom: 10px;
}
#sidebar+#inner-content {
margin-left: 255px;
overflow: hidden;
} }
@ -157,34 +174,25 @@ footer a {
white-space: nowrap; white-space: nowrap;
} }
#inner-content { .unit {
overflow: hidden; margin: 2.5em 0;
padding-bottom: 2em;
} }
#sidebar .unit:first-child {
margin-top: 0;
}
#small-screen { display: none; }
.small-screen #sidebar { @media only screen and (max-width:700px) {
float: none; #small-screen { display: block; }
width: 100%; body #sidebar {
padding: 0 0 1em 0; float: none;
} width: 100%;
.small-screen #inner-content { }
float: none; #inner-content {
width: auto; width: auto;
} margin-left: 0;
margin-bottom: 2em;
.bottom-unit { }
padding: 0.5em 1em;
border: 1px solid #eee;
border-bottom: 0;
padding-bottom: 0;
margin: 1em 0 2em 0;
}
.left-unit {
margin: 0 0 1.5em 0;
padding: 0.75em;
border: 1px solid #eee;
padding-left: 0;
border-left: 0;
} }
@ -205,7 +213,7 @@ hr {
} }
a { a {
color: firebrick; color: hsl(0,70%,45%);
text-decoration: none; text-decoration: none;
outline: 0; outline: 0;
} }
@ -220,7 +228,7 @@ i[class*='icon-'] {
display: inline-block; display: inline-block;
} }
a i[class*='icon-'] { a i[class*='icon-'] {
background-color: firebrick; background-color: hsl(0,70%,45%);
} }
a:focus i[class*='icon-'], a:focus i[class*='icon-'],
a:hover i[class*='icon-'] { a:hover i[class*='icon-'] {
@ -229,87 +237,121 @@ a:hover i[class*='icon-'] {
form.aligned input, .form-row>label {
form.aligned button { display: inline-block;
vertical-align: text-top;
}
form.aligned label {
text-align: right; text-align: right;
vertical-align: middle; vertical-align: middle;
}
form.aligned label.left {
display: inline-block;
padding-right: 1em; padding-right: 1em;
width: 5em; width: 7em;
min-height: 1em; min-height: 1em;
float: left; float: left;
} }
form.aligned>div { label,
margin-bottom: 0.5em; input:not([type=radio]):not([type=checkbox]):not([type=file]),
clear: left; select,
} button {
form.aligned label, -webkit-box-sizing: border-box !important;
form.aligned input, -moz-box-sizing: border-box !important;
form.aligned select, box-sizing: border-box !important;
form.aligned button {
vertical-align: middle; vertical-align: middle;
line-height: 20px; line-height: 24px;
height: 34px;
} }
form.aligned label, label,
form.aligned input, input,
form.aligned select { select {
padding: 5px; padding: 5px;
font-family: inherit;
font-size: 11pt;
} }
form.aligned input[type=file] { input[type=file] {
padding: 5px 0; padding: 5px 0;
} }
form.aligned input[type=radio], input[type=radio],
form.aligned input[type=checkbox] { input[type=checkbox] {
vertical-align: text-top; width: auto;
max-width: auto;
margin: 0 10px 0 0;
padding: 0;
vertical-align: middle;
} }
button {
font-size: 12pt;
border-radius: 5px;
padding: 5px 15px;
-moz-box-sizing: border-box;
color: white;
background: hsl(0,70%,60%);
border: 0;
}
button:hover {
background-color: hsl(0,75%,50%);
cursor: pointer;
}
.form-row {
margin: 0.25em 0;
clear: left;
}
.input-wrapper { .input-wrapper {
overflow: hidden; overflow: hidden;
display: block; display: block;
} }
.input-wrapper ul.tagit,
.input-wrapper input,
.input-wrapper textarea,
.input-wrapper select {
width: 80%;
max-width: 80%;
}
label {
display: inline-block;
}
label,
input,
select,
button {
font-family: inherit;
font-size: 11pt;
}
ul.tagit, ul.tagit,
select, select,
textarea, textarea,
input:not([type=radio]):not([type=checkbox]):not([type=file]) { input:not([type=radio]):not([type=checkbox]):not([type=file]) {
width: 100%;
max-width: 100%;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 3px; border-radius: 5px;
}
ul.tagit {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
} }
ul.tagit input { ul.tagit input {
border: 0 !important; border: 0 !important;
line-height: auto !important;
height: auto !important;
margin: -4px 0 !important;
} }
button {
font-size: 115%;
padding: 0.2em 0.7em;
color: white; .tabs ul {
background: cornflowerblue; list-style-type: none;
border: 0; margin: 0 0 1em 0;
padding: 0;
border-bottom: 3px solid #eee;
} }
button:hover { .tabs li {
background-color: royalblue; display: inline-block;
cursor: pointer; }
.tabs li a {
display: inline-block;
padding: 0.5em 1em;
vertical-align: middle;
border: 3px solid rgba(238, 238, 238, 0);
border-bottom: 3px solid #eee;
color: silver;
margin: 0 0 -3px 0;
}
.tabs li.selected a {
border: 3px solid #eee;
border-bottom-color: rgba(238, 238, 238, 0);
color: inherit;
background: white;
}
.tabs li a:hover,
.tabs li a:focus {
color: hsl(0,70%,45%);
} }
@ -320,7 +362,7 @@ button:hover {
border-style: solid; border-style: solid;
border-width: 1px; border-width: 1px;
max-width: 500px; max-width: 500px;
margin: 0 auto; margin: 2em auto;
} }
.alert-success { .alert-success {
@ -344,11 +386,9 @@ button:hover {
.clear { .clear {
display: block; display: block;
clear: both; clear: both;
} height: 1px; /* ghost top margin in firefox */
width: 100%;
pre.debug { margin: -1px 0 0 0;
text-align: left;
color: black;
} }
.spoiler:before, .spoiler:before,
@ -368,3 +408,26 @@ pre.debug {
.spoiler:hover { .spoiler:hover {
color: black; color: black;
} }
img {
border: 0;
}
blockquote {
margin-left: 0;
border-left: 3px solid #eee;
background: ghostwhite;
padding: 0.5em;
}
blockquote>*:first-child {
margin-top: 0;
}
blockquote>*:last-child {
margin-bottom: 0;
}
.ui-state-default,
.ui-widget-content .ui-state-default,
.ui-widget-header .ui-state-default {
color: hsla(0,70%,45%,0.8) !important;
}

View File

@ -0,0 +1,30 @@
div.debug {
background: #f5f5f5;
font-size: 90%;
margin: 1em 0;
padding: 1em;
color: black;
text-align: left;
}
div.debug pre {
margin-left: 1em;
white-space: normal;
text-indent: -1em;
}
div.debug pre.query {
color: maroon;
}
div.debug pre.bindings {
color: gray;
}
div.debug pre.bindings .value {
color: green;
font-weight: bold;
margin-right: 1em;
}
div.debug pre.query span {
background: rgba(255, 0, 0, 0.05);
}
div.debug pre.query span:hover {
background: white;
}

View File

@ -0,0 +1,7 @@
code {
margin: 0 0.5em;
}
.tab-content {
padding-left: 1em;
}

View File

@ -1,70 +1,66 @@
#sidebar { #welcome {
min-width: 100px;
padding: 5em 0;
width: 25%;
margin-right: 5%;
text-align: center; text-align: center;
} }
#sidebar p { #welcome p {
font-size: small; font-size: small;
margin-top: 0;
} }
#sidebar p span:not(:last-child):after { #welcome p span:not(:last-child):after {
content: '\022C5'; content: '\022C5';
margin: 0 0.5em; margin: 0 0.5em;
} }
#sidebar h1 { #content h1 {
font-size: 26pt; font-size: 26pt;
} margin-top: 1em;
#sidebar input { margin-bottom: 0;
width: 100%;
max-width: 300px;
border: 2px solid #ccc;
padding: 5px;
} }
#inner-content { #content .main-wrapper>* {
float: right; margin: 0 auto;
width: 70%; width: 70%;
position: relative; position: relative;
} }
@media only screen and (max-width:700px) {
#inner-content .header .tags:before { #content .main-wrapper>* {
margin: 0 0.5em; width: 100%;
content: '\2013'; }
} }
#inner-content .header ul {
#content .body {
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAGCAYAAADgzO9IAAAAJElEQVQImWNgYGBgePfu3X8YZoABFA6SIqwS+HXgtANZF7IEAJnGPJE70lLLAAAAAElFTkSuQmCC');
margin-top: 1em;
text-align: center;
}
#content .body img {
max-width: 100%;
margin: 0 auto;
display: block;
}
#content .body a {
display: block;
}
#content .footer {
font-size: small;
color: dimgray;
margin: 0.5em auto 3em auto;
}
#content .footer .left {
float: left;
}
#content .footer .right {
float: right;
}
#content .footer ul {
list-style-type: none; list-style-type: none;
display: inline; display: inline;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
#inner-content .header li { #content .footer li {
display: inline; display: inline;
} }
#inner-content .header li:not(:last-child) a:after { #content .footer li:not(:last-child) a:after {
content: ', '; content: ', ';
} }
#inner-content .body {
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAGCAYAAADgzO9IAAAAJElEQVQImWNgYGBgePfu3X8YZoABFA6SIqwS+HXgtANZF7IEAJnGPJE70lLLAAAAAElFTkSuQmCC');
margin: 1em 0;
text-align: center;
}
#inner-content .body img {
max-width: 100%;
margin: 0 auto;
display: block;
}
#inner-content .body a {
display: block;
}
#inner-content .header .favs-comments {
margin-left: 0.5em;
float: right;
}
#inner-content .footer {
text-align: right;
}

View File

@ -0,0 +1,17 @@
#content form {
margin-bottom: 1em;
}
#content input {
margin: 0 1em;
max-width: 50%;
}
pre {
font-size: 11pt;
margin: 0;
}
pre strong {
background: #fee;
}

View File

@ -10,12 +10,12 @@
.paginator li { .paginator li {
display: inline-block; display: inline-block;
margin-right: 0.5em;
} }
.paginator li a { .paginator li a {
display: inline-block; display: inline-block;
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
margin-right: 0.5em;
background: #eee; background: #eee;
border: 1px solid silver; border: 1px solid silver;
color: black; color: black;
@ -31,3 +31,9 @@
.paginator li.disabled a { .paginator li.disabled a {
color: gray; color: gray;
} }
.paginator li a:focus,
.paginator li a:hover {
border: 1px solid hsl(0,70%,50%);
background: pink;
}

View File

@ -1,4 +1,32 @@
.post { .post {
margin: 0.5em; margin: 8px;
float: left; }
.posts-wrapper {
text-align: center;
}
.posts {
margin: -8px auto 0 auto;
}
.form-wrapper {
text-align: center;
margin-bottom: 1em;
}
.small-screen .form-wrapper {
width: 100%;
}
#content form {
margin: 0 auto;
width: 24em;
text-align: left;
}
#content form label {
width: 9em;
}
#content form h1 {
display: none;
}
li.mass-tag {
float: right;
} }

View File

@ -1,62 +1,132 @@
.post { .post {
border: 1px solid #ddd;
box-shadow: 0.25em 0.25em #eee;
padding: 0; padding: 0;
position: relative; position: relative;
display: inline-block; display: inline-block;
} }
.post-type-flash { .post .link {
border-color: #dd5; border: 1px solid #ddd;
box-shadow: 0.25em 0.25em #eeb, 0.1em 0.1em 0.5em 0.1em rgba(238,238,187,0.5); box-shadow: 0.25em 0.25em #eee;
color: black;
} }
.post:focus, .post-type-youtube:after,
.post:hover { .post-type-flash:after {
border: 1px solid firebrick; position: absolute;
right: 1px; /* border */
top: 1px; /* border */
width: 150px;
height: 150px;
content: ' ';
pointer-events: none;
}
.post-type-flash {
border-color: red;
}
.post-type-youtube {
border-color: red;
}
.post-type-flash:after {
background: url('../img/thumb-overlay-swf.png');
}
.post-type-youtube:after {
background: url('../img/thumb-overlay-yt.png');
}
.post .toggle-tag {
position: absolute;
z-index: 2;
width: 100px;
height: 30px;
background: whitesmoke;
opacity: .5;
border: 1px solid black;
margin: 60px 25px;
line-height: 30px;
}
.post .toggle-tag:focus,
.post .toggle-tag:hover {
opacity: 1;
}
.post.taggable.tagged .toggle-tag {
background-color: #0f0;
color: black;
}
.post.taggable:not(.tagged) .toggle-tag {
background-color: #f00;
color: white;
}
.post .link {
z-index: 1;
}
.post .link:focus,
.post .link:hover {
border: 1px solid hsl(0,70%,50%);
box-shadow: 0.25em 0.25em pink; box-shadow: 0.25em 0.25em pink;
} }
.post:focus img.thumb, .post .link:focus img.thumb,
.post:hover img.thumb { .post .link:hover img.thumb {
opacity: .9; opacity: .9;
} }
.post a {
display: inline-block;
vertical-align: top;
}
.post img.thumb { .post img.thumb {
width: 140px; display: inline-block;
height: 140px; width: 150px;
display: block; height: 150px;
vertical-align: top;
} }
.post .info-bar:before {
border-top: 1px solid hsl(0,70%,50%);
margin-bottom: -1px;
content: '';
display: block;
}
.post .info-bar { .post .info-bar {
display: none; display: none;
height: 20px;
width: 100%;
border-top: 1px solid firebrick;
background: rgba(255, 128, 128, 0.75); background: rgba(255, 128, 128, 0.75);
position: absolute; position: absolute;
bottom: 0; z-index: 3;
left: 1px; /* border */
right: 1px; /* border */
bottom: 1px; /* border */
text-align: center;
} }
.post:hover .info-bar { .post .link:focus .info-bar,
.post .link:hover .info-bar {
display: block; display: block;
} }
.post .icon-score {
background-position: -85px -1px;
}
.post .icon-comments { .post .icon-comments {
margin-left: 3px;
background-position: -64px -1px; background-position: -64px -1px;
} }
.post .icon-favs { .post .icon-favs {
background-position: -43px -1px; background-position: -43px -1px;
} }
.post [class^='icon-'] { .post .link [class^='icon-'] {
opacity: .75; opacity: .75;
background-color: transparent;
width: 20px; width: 20px;
height: 20px; height: 20px;
line-height: 20px; line-height: 20px;
vertical-align: top; vertical-align: top;
} }
.post span { .post .link span {
vertical-align: top; vertical-align: top;
font-size: small; font-size: small;
line-height: 20px; line-height: 20px;
margin-right: 0.5em; margin-right: 0.5em;
display: inline-block; display: inline-block;
} }
.post .link span.inactive {
display: none;
}

View File

@ -8,6 +8,10 @@
float: left; float: left;
} }
#upload-step1 {
display: table;
width: 100%;
}
#file-handler-wrapper { #file-handler-wrapper {
display: table; display: table;
width: 100%; width: 100%;
@ -17,37 +21,51 @@
font-size: 150%; font-size: 150%;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
height: 300px; height: 8em;
display: table-cell; display: table-cell;
border: 3px dashed #ddd; border: 3px dashed #ddd;
} }
#file-handler.active { #file-handler.active {
background: #eee; background: #eee;
border-color: firebrick; border-color: hsl(0,70%,50%);
}
#url-handler {
margin-top: 0.5em;
position: relative;
}
#url-handler .input-wrapper {
margin-right: 8.5em;
}
#url-handler button {
position: absolute;
top: 0;
right: 0;
width: 8em;
} }
.post .thumbnail { .post .thumbnail {
width: 100px; width: 150px;
height: 100px; height: 150px;
line-height: 100px; line-height: 150px;
background-image: url('../img/thumb-upload.png'); background-image: url('../img/thumb.jpg');
background-size: 150px 150px;
border: 1px solid black; border: 1px solid black;
vertical-align: middle; vertical-align: middle;
text-align: center; text-align: center;
display: block; display: block;
float: left; float: left;
margin-right: 1em; margin-right: 10px;
} }
.post .alert, .post .alert,
#upload-step2, #upload-step2,
#upload-no-posts,
#post-template { #post-template {
display: none; display: none;
} }
.post { .post {
margin-bottom: 4em; margin: 2em 0;
} }
.post .ops { .post .ops {
@ -96,34 +114,23 @@
font-size: 130%; font-size: 130%;
} }
.post label {
line-height: 33px;
}
.post label.left {
display: inline-block;
width: 4em;
float: left;
}
.post .safety label:not(.left) {
margin-right: 0.75em;
}
.post .file-name strong { .post .file-name strong {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 50%; max-width: 50%;
white-space: pre;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
line-height: 33px; padding: 0.5em 0;
} }
.safety-sfw { .safety-safe {
color: #63ca63; color: #43aa43;
} }
.safety-sketchy { .safety-sketchy {
color: #f4c657; color: #d4a627;
} }
.safety-nsfw { .safety-unsafe {
color: #df4b0d; color: #df4b0d;
} }
@ -137,10 +144,27 @@ ul.tagit {
.submit-wrapper { .submit-wrapper {
text-align: center; text-align: center;
} }
#theSubmit { #the-submit {
margin: 0 auto; margin: 0 auto;
font-size: 14.5pt;
padding: 0.35em 2em;
height: auto;
line-height: auto;
} }
.post .form-wrapper { .post .form-wrapper {
overflow: hidden; overflow: hidden;
} }
#lightbox {
display: none;
position: absolute;
pointer-events: none;
max-width: 400px;
max-height: 400px;
margin-top: -50%;
margin-left: -50%;
background: white;
border: 0.5em solid white;
box-shadow: 0 0 0 3px #eee;
}

View File

@ -1,5 +1,5 @@
#sidebar { #sidebar {
width: 200px; width: 224px;
line-height: 1.33em; line-height: 1.33em;
font-size: 90%; font-size: 90%;
} }
@ -10,51 +10,100 @@ embed {
} }
.post-type-image img { .post-type-image img {
background: url('../img/bk-image.png') lemonchiffon; /*background: url('../img/bk-image.png') lemonchiffon;*/
} }
.post-type-flash embed { .post-type-flash iframe {
background: url('../img/bk-swf.png') lemonchiffon; border: 0;
/*background: url('../img/bk-swf.png') lemonchiffon;*/
} }
#sidebar .relations ul,
#sidebar .tags ul { #sidebar .tags ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
#sidebar .tags li {
overflow: hidden;
text-overflow: ellipsis;
}
#sidebar .tags li .count { #sidebar .tags li .count {
padding-left: 0.5em; padding-left: 0.5em;
color: silver; color: silver;
} }
#sidebar nav { #around {
margin-bottom: 2em; margin-bottom: 2em;
} }
#sidebar nav .left { #around .text {
font-size: 90%;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
margin-top: 0.75em;
}
#around .left {
float: left; float: left;
} }
#sidebar nav .right { #around .right {
float: right; float: right;
} }
#sidebar nav a.disabled { #around a.disabled {
color: silver; color: silver;
} }
#sidebar nav a.disabled i[class*='icon-'] { #around a.disabled i[class*='icon-'] {
background-color: silver; background-color: silver;
} }
#sidebar .uploader .date {
font-size: 9pt !important;
color: gray;
display: inline-block;
position: relative;
top: -5px;
}
#sidebar .uploader img {
vertical-align: text-top;
float: left;
margin: 3px 8px 0 0;
width: 25px;
height: 25px;
background-image: url('http://www.gravatar.com/avatar/0?f=y&d=mm&s=25');
}
#sidebar .unit.details { margin-bottom: 1.5em; }
#sidebar .unit.hl-options { margin-top: 1.5em; }
#sidebar .safety-safe {
color: #43aa43;
}
#sidebar .safety-sketchy {
color: #d4a627;
}
#sidebar .safety-unsafe {
color: #df4b0d;
}
#sidebar .score .selected {
font-weight: bold;
}
i.icon-prev { i.icon-prev {
background-position: -12px -1px; background-position: -12px -1px;
margin-left: 8px;
} }
i.icon-next { i.icon-next {
background-position: -1px -1px; background-position: -1px -1px;
margin-right: 8px;
} }
i.icon-prev, i.icon-prev,
i.icon-next { i.icon-next {
margin: 0 8px;
vertical-align: middle; vertical-align: middle;
width: 8px; width: 8px;
height: 20px; height: 20px;
} }
i.icon-dl { i.icon-dl {
margin: 0; margin: 0;
width: 20px; width: 20px;
@ -62,14 +111,33 @@ i.icon-dl {
background-position: -22px -1px; background-position: -22px -1px;
} }
.permalink { i.icon-edit {
margin: 1em 0; margin: 0;
width: 20px;
height: 20px;
background-position: -43px -22px;
} }
.permalink .icon-dl {
i.icon-fav {
margin: 0;
width: 20px;
height: 20px;
}
.add-fav i.icon-fav {
background-position: -1px -22px;
}
.rem-fav i.icon-fav {
background-position: -22px -22px;
}
.hl-option {
margin: 0.4em 0;
}
.hl-option i[class^='icon'] {
vertical-align: middle; vertical-align: middle;
margin-right: 1em;
} }
.permalink span { .hl-option span {
padding-left: 0.6em;
vertical-align: middle; vertical-align: middle;
} }
.permalink .ext:after { .permalink .ext:after {
@ -97,11 +165,27 @@ i.icon-dl {
margin: 2px; margin: 2px;
} }
form.edit-post { #inner-content {
position: relative;
}
.unit.edit-post {
position: absolute;
margin-top: 0;
padding: 1em;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 1em 1em rgba(255, 255, 255, 0.8);
z-index: 99;
top: 0;
left: 0;
right: 0;
display: none; display: none;
} }
form.edit-post .safety label:not(.left) { .unit.edit-post ul.tagit,
margin-right: 0.75em; .unit.edit-post input:not([type=file]) {
background: rgba(255, 255, 255, 0.75);
}
.unit.edit-post ul.tagit input {
background: transparent;
} }
ul.tagit { ul.tagit {
display: block; display: block;
@ -109,15 +193,3 @@ ul.tagit {
margin: 0; margin: 0;
font-size: 1em; font-size: 1em;
} }
.preview {
border: 1px solid yellow;
background: url('../img/preview.png') lemonchiffon;
padding: 0.5em;
display: none;
}
form.add-comment textarea {
width: 50em;
height: 8em;
}

View File

@ -11,26 +11,48 @@
text-align: top; text-align: top;
width: 14em; width: 14em;
display: inline-block; display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.form-wrapper { .form-wrapper {
width: 50%; max-width: 24em;
display: inline-block;
text-align: center;
} }
.small-screen .form-wrapper { .small-screen .form-wrapper {
width: 100%; width: 100%;
} }
form.aligned { #content form label {
text-align: left; width: 9em;
margin: 2em auto;
} }
form.aligned label.left { #content form h1 {
width: 7em; display: none;
} }
form.aligned input {
width: 24em; .frequency0 { color: #ecc8c8; }
} .frequency1 { color: #e6b7b7; }
form h1 { .frequency2 { color: #e0a7a7; }
.frequency3 { color: #db9696; }
.frequency4 { color: #d58686; }
.frequency5 { color: #cf7575; }
.frequency6 { color: #c96464; }
.frequency7 { color: #c35454; }
.frequency8 { color: #be4343; }
.frequency9 { color: #b83333; }
.frequency10 { color: #b22222; }
nav.sort-styles ul {
list-style-type: none;
margin: 0 0 2.5em 0;
text-align: center; text-align: center;
padding: 0;
}
nav.sort-styles li {
display: inline-block;
font-size: 105%;
margin: 0 1em;
padding-bottom: 0.2em;
}
nav.sort-styles li.active {
border-bottom: 3px solid hsl(0,70%,50%);
} }

View File

@ -1,30 +1,6 @@
.user img {
width: 100px;
height: 100px;
float: left;
margin-right: 0.5em;
}
.user h1 {
margin-top: 0;
margin-bottom: 0.25em;
}
.user {
line-height: 1.5em;
margin-bottom: 1em;
margin-right: 1em;
display: inline-block;
}
.user .details {
display: inline-block;
max-width: 25em;
white-space: pre;
}
nav.sort-styles ul { nav.sort-styles ul {
list-style-type: none; list-style-type: none;
margin: 0 0 1em 0; margin: 0 0 2.5em 0;
text-align: center; text-align: center;
padding: 0; padding: 0;
} }
@ -35,5 +11,41 @@ nav.sort-styles li {
padding-bottom: 0.2em; padding-bottom: 0.2em;
} }
nav.sort-styles li.active { nav.sort-styles li.active {
border-bottom: 3px solid firebrick; border-bottom: 3px solid hsl(0,70%,50%);
}
.users-wrapper {
text-align: center;
}
.users {
column-width: 20em;
-moz-column-width: 20em;
-webkit-column-width: 20em;
}
.user {
text-align: initial;
line-height: 1.5em;
margin-bottom: 1em;
margin-right: 1em;
white-space: pre;
}
.user a.avatar {
display: block;
float: left;
}
.user img {
width: 100px;
height: 100px;
margin-right: 1em;
}
.user .details {
display: inline-block;
text-overflow: ellipsis;
}
.user h1 {
margin-top: 0;
margin-bottom: 0.25em;
} }

View File

@ -1,35 +1,7 @@
#sidebar { #sidebar {
width: 220px;
font-size: 90%; font-size: 90%;
} }
.tabs ul {
list-style-type: none;
margin: 0 0 1em 0;
padding: 0;
border-bottom: 1px solid #ccc;
}
.tabs li {
display: inline-block;
}
.tabs li a {
display: inline-block;
padding: 0.5em 1em;
margin-bottom: -1px;
}
.tabs li a {
border: 1px solid white;
border-bottom: 1px solid #ccc;
color: silver;
}
.tabs li.selected a {
border: 1px solid #ccc;
border-bottom: 1px solid white;
color: inherit;
}
.avatar-wrapper { .avatar-wrapper {
text-align: center; text-align: center;
} }
@ -40,16 +12,12 @@
padding: 0; padding: 0;
} }
form.edit label.left { #content form {
width: 9em; max-width: 30em;
} }
#content form label {
form.edit .alert { width: 10em;
}
#content form .alert {
margin: 1em 0; margin: 1em 0;
} }
form.edit select,
form.edit input {
width: 16em;
max-width: 90%;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

View File

@ -0,0 +1,125 @@
$(function()
{
function onDomUpdate()
{
$('form.edit-comment textarea, form.add-comment textarea')
.bindOnce('exit-confirmation', 'change keyp', function(e)
{
enableExitConfirmation();
});
$('form.edit-comment, form.add-comment')
.bindOnce('comment-submit', 'submit', function(e)
{
e.preventDefault();
rememberLastSearchQuery();
var formDom = $(this);
if (formDom.hasClass('inactive'))
return;
formDom.addClass('inactive');
formDom.find(':input').attr('readonly', true);
var url = formDom.attr('action') + '?json';
var fd = new FormData(formDom[0]);
var preview = false;
$.each(formDom.serializeArray(), function(i, x)
{
if (x.name == 'sender' && x.value == 'preview')
preview = true;
});
var ajaxData =
{
url: url,
data: fd,
processData: false,
contentType: false,
type: 'POST',
success: function(data)
{
if (data['success'])
{
if (preview)
{
formDom.find('.preview').html(data['textPreview']).show();
}
else
{
disableExitConfirmation();
formDom.find('.preview').hide();
var cb = function()
{
$.get(window.location.href, function(data)
{
$('.comments-wrapper').replaceWith($(data).find('.comments-wrapper'));
$('body').trigger('dom-update');
});
}
if (formDom.hasClass('add-comment'))
{
cb();
formDom.find('textarea').val('');
}
else
{
formDom.slideUp(function()
{
cb();
$(this).remove();
});
}
}
formDom.find(':input').attr('readonly', false);
formDom.removeClass('inactive');
}
else
{
alert(data['message']);
formDom.find(':input').attr('readonly', false);
formDom.removeClass('inactive');
}
},
error: function()
{
alert('Fatal error');
formDom.find(':input').attr('readonly', false);
formDom.removeClass('inactive');
}
};
$.ajax(ajaxData);
});
$('.comment .edit a').bindOnce('edit-comment', 'click', function(e)
{
e.preventDefault();
var commentDom = $(this).parents('.comment');
var formDom = commentDom.find('form.edit-comment');
var cb = function(formDom)
{
formDom.slideToggle();
$('body').trigger('dom-update');
};
if (formDom.length == 0)
{
$.get($(this).attr('href'), function(data)
{
var otherForm = $(data).find('form.edit-comment');
otherForm.hide();
commentDom.find('.body').append(otherForm);
formDom = commentDom.find('form.edit-comment');
cb(formDom);
});
}
else
cb(formDom);
});
}
$('body').bind('dom-update', onDomUpdate);
});

View File

@ -1,24 +1,56 @@
$.fn.hasAttr = function(name) { function setCookie(name, value, exdays)
{
var exdate = new Date();
exdate.setDate(exdate.getDate() + exdays);
value = escape(value) + '; path=/' + ((exdays == null) ? '' : '; expires=' + exdate.toUTCString());
document.cookie = name + '=' + value;
}
function getCookie(name)
{
var value = document.cookie;
var start = value.indexOf(' ' + name + '=');
if (start == -1)
start = value.indexOf(name + '=');
if (start == -1)
return null;
start = value.indexOf('=', start) + 1;
var end = value.indexOf(';', start);
if (end == -1)
end = value.length;
return unescape(value.substring(start, end));
}
function rememberLastSearchQuery()
{
//lastSearchQuery variable is obtained from layout
setCookie('last-search-query', lastSearchQuery);
}
//core functionalities, prototypes
$.fn.hasAttr = function(name)
{
return this.attr(name) !== undefined; return this.attr(name) !== undefined;
}; };
if ($.when.all === undefined) $.fn.bindOnce = function(name, eventName, callback)
{ {
$.when.all = function(deferreds) $.each(this, function(i, item)
{ {
var deferred = new $.Deferred(); if ($(item).data(name) == name)
$.when.apply($, deferreds).then(function() return;
{ $(item).data(name, name);
deferred.resolve(Array.prototype.slice.call(arguments, 0)); $(item).on(eventName, callback);
}, function() });
{ };
deferred.fail(Array.prototype.slice.call(arguments, 0));
});
return deferred;
}
}
//safety trigger
$(function() $(function()
{ {
$('.safety a').click(function(e) $('.safety a').click(function(e)
@ -31,94 +63,269 @@ $(function()
aDom.addClass('inactive'); aDom.addClass('inactive');
var url = $(this).attr('href') + '?json'; var url = $(this).attr('href') + '?json';
$.get(url, function(data) $.get(url).always(function(data)
{ {
if (data['success']) if (data['success'])
{
window.location.reload(); window.location.reload();
}
else else
{ {
alert(data['errorMessage']); alert(data['message'] ? data['message'] : 'Fatal error');
}
});
});
function confirmEvent(e)
{
if (!confirm($(this).attr('data-confirm-text')))
{
e.preventDefault();
e.stopPropagation();
}
}
$('form[data-confirm-text]').submit(confirmEvent);
$('a[data-confirm-text]').click(confirmEvent);
$('a.simple-action').click(function(e)
{
if(e.isPropagationStopped())
return;
e.preventDefault();
var aDom = $(this);
if (aDom.hasClass('inactive'))
return;
aDom.addClass('inactive');
var url = $(this).attr('href') + '?json';
$.get(url, function(data)
{
if (data['success'])
{
if (aDom.hasAttr('data-redirect-url'))
window.location.href = aDom.attr('data-redirect-url');
else
window.location.reload();
}
else
{
alert(data['errorMessage']);
aDom.removeClass('inactive'); aDom.removeClass('inactive');
} }
}); });
}); });
});
//attach data from submit buttons to forms before .submit() gets called
$(':submit').each(function() //basic event listeners
$(function()
{
$('body').bind('dom-update', function()
{ {
$(this).click(function() //event confirmations
function confirmEvent(e)
{ {
var form = $(this).closest('form'); if (!confirm($(this).attr('data-confirm-text')))
form.find('.faux-submit').remove(); {
var input = $('<input class="faux-submit" type="hidden"/>').attr({ e.preventDefault();
name: $(this).attr('name'), e.stopPropagation();
value: $(this).val() }
}
$('form.confirmable').bindOnce('confirmation', 'submit', confirmEvent);
$('a.confirmable').bindOnce('confirmation', 'click', confirmEvent);
//simple action buttons
$('a.simple-action').bindOnce('simple-action', 'click', function(e)
{
if (e.isPropagationStopped())
return;
e.preventDefault();
rememberLastSearchQuery();
var aDom = $(this);
if (aDom.hasClass('inactive'))
return;
aDom.addClass('inactive');
var url = $(this).attr('href') + '?json';
$.get(url, {submit: 1}).always(function(data)
{
if (data['success'])
{
if (aDom.hasAttr('data-redirect-url'))
window.location.href = aDom.attr('data-redirect-url');
else if (aDom.data('callback'))
aDom.data('callback')();
else
window.location.reload();
}
else
{
alert(data['message'] ? data['message'] : 'Fatal error');
aDom.removeClass('inactive');
}
});
});
//attach data from submit buttons to forms before .submit() gets called
$('.submit').each(function()
{
$(this).bindOnce('submit-faux-input', 'click', function()
{
var form = $(this).closest('form');
form.find('.faux-submit').remove();
var input = $('<input class="faux-submit" type="hidden"/>').attr({
name: $(this).attr('name'),
value: $(this).val()
});
form.append(input);
}); });
form.append(input);
}); });
}); });
$(window).resize();
//try to remember last search query
window.onbeforeunload = rememberLastSearchQuery;
}); });
$(window).resize(function()
//modify DOM on small viewports
function processSidebar()
{ {
//modify DOM on small viewports if ($('#small-screen').is(':visible'))
$('#inner-content .unit').addClass('bottom-unit');
if ($('body').width() < 600)
{
$('body').addClass('small-screen');
$('#sidebar').insertAfter($('#inner-content')); $('#sidebar').insertAfter($('#inner-content'));
$('#sidebar .unit').removeClass('left-unit').addClass('bottom-unit');
}
else else
$('#sidebar').insertBefore($('#inner-content'));
}
$(function()
{
$(window).resize(function()
{ {
$('body').removeClass('small-screen'); fixSize();
$('#inner-content').insertAfter($('#sidebar')); if ($('body').width() == $('body').data('last-width'))
$('#sidebar .unit').removeClass('bottom-unit').addClass('left-unit'); return;
} $('body').data('last-width', $('body').width());
$('body').trigger('dom-update');
});
$('body').bind('dom-update', processSidebar);
fixSize();
}); });
var fixedEvenOnce = false;
function fixSize()
{
var multiply = 168;
var oldWidth = $('.main-wrapper:eq(0)').width();
$('.main-wrapper:eq(0)').width('');
var newWidth = $('.main-wrapper:eq(0)').width();
if (oldWidth != newWidth || !fixedEvenOnce)
{
$('.main-wrapper').width(multiply * Math.floor(newWidth / multiply));
fixedEvenOnce = true;
}
}
//autocomplete
function split(val)
{
return val.split(/\s+/);
}
function extractLast(term)
{
return split(term).pop();
}
function retrieveTags(searchTerm, cb)
{
var options = { filter: searchTerm + ' order:popularity,desc' };
$.getJSON('/tags?json', options, function(data)
{
var tags = $.map(data.tags.slice(0, 15), function(tag)
{
var ret =
{
label: tag.name + ' (' + tag.count + ')',
value: tag.name,
};
return ret;
});
cb(tags);
});
}
$(function()
{
$('.autocomplete').each(function()
{
var options =
{
minLength: 1,
source: function(request, response)
{
var term = extractLast(request.term);
if (term != '')
retrieveTags(term, response);
},
focus: function(e)
{
// prevent value inserted on focus
e.preventDefault();
},
select: function(e, ui)
{
e.preventDefault();
var terms = split(this.value);
terms.pop();
terms.push(ui.item.value);
terms.push('');
this.value = terms.join(' ');
}
};
if ($(this).parents('#top-nav').length != 0)
{
options['position'] =
{
my: 'right top',
at: 'right bottom'
};
}
var searchInput = $(this);
searchInput
// don't navigate away from the field on tab when selecting an item
.bind('keydown', function(e)
{
if (e.keyCode === $.ui.keyCode.TAB && $(this).data('autocomplete').menu.active)
e.preventDefault();
}).autocomplete(options);
});
});
function attachTagIt(element)
{
var tagItOptions =
{
caseSensitive: false,
autocomplete:
{
source:
function(request, response)
{
var tagit = this;
retrieveTags(request.term.toLowerCase(), function(tags)
{
if (!tagit.options.allowDuplicates)
{
tags = $.grep(tags, function(tag)
{
return tagit.assignedTags().indexOf(tag.value) == -1;
});
}
response(tags);
});
},
}
};
tagItOptions.placeholderText = element.attr('placeholder');
element.tagit(tagItOptions);
}
//hotkeys
$(function()
{
Mousetrap.bind('q', function() { $('#top-nav input').focus(); return false; }, 'keyup');
Mousetrap.bind('w', function() { $('body,html').animate({scrollTop: '-=150px'}, 200); });
Mousetrap.bind('s', function() { $('body,html').animate({scrollTop: '+=150px'}, 200); });
Mousetrap.bind('a', function() { var url = $('.paginator:visible .prev:not(.disabled) a').attr('href'); if (typeof url !== 'undefined') window.location.href = url; }, 'keyup');
Mousetrap.bind('d', function() { var url = $('.paginator:visible .next:not(.disabled) a').attr('href'); if (typeof url !== 'undefined') window.location.href = url; }, 'keyup');
Mousetrap.bind('p', function() { $('.post a').eq(0).focus(); return false; }, 'keyup');
});
function enableExitConfirmation()
{
$(window).bind('beforeunload', function(e)
{
return 'There are unsaved changes.';
});
}
function disableExitConfirmation()
{
$(window).unbind('beforeunload');
}

View File

@ -0,0 +1,4 @@
$(function()
{
$('#content form input').eq(0).focus().select();
});

View File

@ -18,6 +18,7 @@ function scrolled()
var nextPage = dom.find('.paginator .next:not(.disabled) a').attr('href'); var nextPage = dom.find('.paginator .next:not(.disabled) a').attr('href');
$(document).data('page-next', nextPage); $(document).data('page-next', nextPage);
$('.paginator-content').append($(response).find('.paginator-content').children().css({opacity: 0}).animate({opacity: 1}, 'slow')); $('.paginator-content').append($(response).find('.paginator-content').children().css({opacity: 0}).animate({opacity: 1}, 'slow'));
$('body').trigger('dom-update');
scrolled(); scrolled();
}); });
} }

View File

@ -0,0 +1,37 @@
$(function()
{
$('body').bind('dom-update', function()
{
$('.post a.toggle-tag').bindOnce('toggle-tag', 'click', function(e)
{
e.preventDefault();
var aDom = $(this);
if (aDom.hasClass('inactive'))
return;
aDom.addClass('inactive');
var enable = !aDom.parents('.post').hasClass('tagged');
var url = $(this).attr('href') + '?json';
url = url.replace('_enable_', enable ? '1' : '0');
$.get(url, {submit: 1}).always(function(data)
{
if (data['success'])
{
aDom.removeClass('inactive');
aDom.parents('.post').removeClass('tagged');
if (enable)
aDom.parents('.post').addClass('tagged');
aDom.text(enable
? aDom.attr('data-text-tagged')
: aDom.attr('data-text-untagged'));
}
else
{
alert(data['message'] ? data['message'] : 'Fatal error');
aDom.removeClass('inactive');
}
});
});
});
});

View File

@ -0,0 +1,270 @@
$(function()
{
$('.tabs a').click(function(e)
{
e.preventDefault();
var className = $(this).parents('li').attr('class').replace('selected', '').replace(/^\s+|\s+$/, '');
$('.tabs li').removeClass('selected');
$(this).parents('li').addClass('selected');
$('.tab-content').hide();
$('.tab-content.' + className).show();
});
$('#file-handler').on('dragenter', function(e)
{
$(this).addClass('active');
}).on('dragleave', function(e)
{
$(this).removeClass('active');
}).on('dragover', function(e)
{
e.preventDefault();
}).on('drop', function(e)
{
e.preventDefault();
handleFiles(e.originalEvent.dataTransfer.files);
$(this).trigger('dragleave');
}).on('click', function(e)
{
$(':file').show().focus().trigger('click').hide();
});
$(':file').change(function(e)
{
handleFiles(this.files);
});
$('#url-handler-wrapper input').keydown(function(e)
{
if (e.which == 13)
{
$('#url-handler-wrapper button').trigger('click');
e.preventDefault();
}
});
$('#url-handler-wrapper button').click(function(e)
{
var url = $('#url-handler-wrapper input').val();
url = url.replace(/^\s+|\s+$/, '');
if (url == '')
return;
$('#url-handler-wrapper input').val('');
handleURLs([url]);
});
$('.post .move-down-trigger, .post .move-up-trigger').on('click', function()
{
if ($('#the-submit').hasClass('inactive'))
return;
var dir = $(this).hasClass('move-down-trigger') ? 'd' : 'u';
var post = $(this).parents('.post');
if (dir == 'u')
post.insertBefore(post.prev('.post'));
else
post.insertAfter(post.next('.post'));
});
$('.post .remove-trigger').on('click', function()
{
if ($('#the-submit').hasClass('inactive'))
return;
$(this).parents('.post').slideUp(function()
{
$(this).remove();
handleInputs([]);
});
});
function sendNextPost()
{
var posts = $('#upload-step2 .post');
if (posts.length == 0)
{
uploadFinished();
return;
}
var postDom = posts.first();
var url = postDom.find('form').attr('action') + '?json';
var fd = new FormData(postDom.find('form').get(0));
fd.append('file', postDom.data('file'));
fd.append('url', postDom.data('url'));
var ajaxData =
{
url: url,
data: fd,
processData: false,
contentType: false,
dataType: 'json',
type: 'POST',
success: function(data)
{
if (data['success'])
{
postDom.slideUp(function()
{
postDom.remove();
sendNextPost();
});
}
else
{
postDom.find('.alert').html(data['messageHtml']).slideDown();
enableUpload();
}
},
error: function(data)
{
postDom.find('.alert').html('Fatal error').slideDown();
enableUpload();
}
};
$.ajax(ajaxData);
}
function uploadFinished()
{
disableExitConfirmation();
window.location.href = $('#upload-step2').attr('data-redirect-url');
}
function disableUpload()
{
var theSubmit = $('#the-submit');
theSubmit.addClass('inactive');
var posts = $('#upload-step2 .post');
posts.find(':input').attr('readonly', true);
posts.addClass('inactive');
}
function enableUpload()
{
var theSubmit = $('#the-submit');
theSubmit.removeClass('inactive');
var posts = $('#upload-step2 .post');
posts.removeClass('inactive');
posts.find(':input').attr('readonly', false);
}
$('#the-submit').click(function(e)
{
e.preventDefault();
var theSubmit = $(this);
if (theSubmit.hasClass('inactive'))
return;
disableUpload();
sendNextPost();
});
function handleFiles(files)
{
handleInputs(files, function(postDom, file)
{
postDom.data('file', file);
$('.file-name strong', postDom).text(file.name);
if (file.type.match('image.*'))
{
var img = postDom.find('img')
var reader = new FileReader();
reader.onload = (function(theFile, img)
{
return function(e)
{
changeThumb(img, e.target.result);
};
})(file, img);
reader.readAsDataURL(file);
}
});
}
function changeThumb(img, url)
{
$(img)
.css('background-image', 'none')
.attr('src', url)
.data('custom-thumb', true);
}
function handleURLs(urls)
{
handleInputs(urls, function(postDom, url)
{
postDom.data('url', url);
postDom.find('[name=source]').val(url);
if (matches = url.match(/watch.*?=([a-zA-Z0-9_-]+)/))
{
postDom.find('.file-name strong').text(url);
$.getJSON('http://gdata.youtube.com/feeds/api/videos/' + matches[1] + '?v=2&alt=jsonc', function(data)
{
postDom.find('.file-name strong')
.text(data.data.title);
changeThumb(postDom.find('img'), data.data.thumbnail.hqDefault);
});
}
else
{
postDom.find('.file-name strong').text(url);
changeThumb(postDom.find('img'), url);
}
});
}
function handleInputs(inputs, callback)
{
for (var i = 0; i < inputs.length; i ++)
{
var input = inputs[i];
var postDom = $('#post-template').clone(true);
postDom.find('form').submit(false);
postDom.removeAttr('id');
$('.posts').append(postDom);
postDom.show();
attachTagIt($('.tags input', postDom));
callback(postDom, input);
}
if ($('.posts .post').length == 0)
{
disableExitConfirmation();
$('#upload-step2').fadeOut();
}
else
{
enableExitConfirmation();
$('#upload-step2').fadeIn();
}
}
$('.post img').mouseenter(function(e)
{
if ($(this).data('custom-thumb') != true)
return;
$('#lightbox')
.attr('src', $(this).attr('src'))
.show()
.position({
of: $(this),
my: 'center center',
at: 'center center',
})
.show();
});
$('.post img').mouseleave(function(e)
{
$('#lightbox').hide();
});
});

View File

@ -1,36 +1,95 @@
$(function() $(function()
{ {
$('li.edit a').click(function(e) function onDomUpdate()
{ {
e.preventDefault(); $('#sidebar a.edit-post').bindOnce('edit-post', 'click', function(e)
var aDom = $(this);
if (aDom.hasClass('inactive'))
return;
aDom.addClass('inactive');
var tags = [];
$.getJSON('/tags?json', function(data)
{ {
tags = data['tags'];
var tagItOptions =
{
caseSensitive: true,
availableTags: tags,
placeholderText: $('.tags input').attr('placeholder')
};
$('.tags input').tagit(tagItOptions);
e.preventDefault(); e.preventDefault();
var aDom = $(this);
if (aDom.hasClass('inactive'))
return;
aDom.addClass('inactive');
var formDom = $('form.edit-post'); var formDom = $('form.edit-post');
formDom.show().css('height', formDom.height()).hide().slideDown(); if (formDom.find('.tagit').length == 0)
{
attachTagIt($('.tags input'));
aDom.removeClass('inactive');
formDom.find('input[type=text]:visible:eq(0)').focus();
formDom.find('textarea, input').bind('change keyup', function()
{
if (formDom.serialize() != formDom.data('original-data'))
enableExitConfirmation();
});
}
else
aDom.removeClass('inactive');
var editUnit = formDom.parents('.unit');
var postUnit = $('.post-wrapper');
if (!$(formDom).is(':visible'))
{
formDom.data('original-data', formDom.serialize());
editUnit.show();
var editUnitHeight = formDom.height();
editUnit.css('height', editUnitHeight);
editUnit.hide();
if (postUnit.height() < editUnitHeight)
postUnit.animate({height: editUnitHeight + 'px'}, 'fast');
editUnit.slideDown('fast', function()
{
$(this).css('height', 'auto');
});
}
else
{
editUnit.slideUp('fast');
var postUnitOldHeight = postUnit.height();
postUnit.height('auto');
var postUnitHeight = postUnit.height();
postUnit.height(postUnitOldHeight);
if (postUnitHeight != postUnitOldHeight)
postUnit.animate({height: postUnitHeight + 'px'});
if ($('.post-wrapper').height() < editUnitHeight)
$('.post-wrapper').animate({height: editUnitHeight + 'px'});
return;
}
formDom.find('input[type=text]:visible:eq(0)').focus();
}); });
});
$('.comments.unit a.simple-action').data('callback', function()
{
$.get(window.location.href, function(data)
{
$('.comments-wrapper').replaceWith($(data).find('.comments-wrapper'));
$('body').trigger('dom-update');
});
});
$('#sidebar a.simple-action').data('callback', function()
{
$.get(window.location.href, function(data)
{
$('#sidebar').replaceWith($(data).find('#sidebar'));
$('body').trigger('dom-update');
});
});
}
$('body').bind('dom-update', onDomUpdate);
$('form.edit-post').submit(function(e) $('form.edit-post').submit(function(e)
{ {
e.preventDefault(); e.preventDefault();
rememberLastSearchQuery();
var formDom = $(this); var formDom = $(this);
if (formDom.hasClass('inactive')) if (formDom.hasClass('inactive'))
@ -53,72 +112,35 @@ $(function()
{ {
if (data['success']) if (data['success'])
{ {
window.location.reload(); disableExitConfirmation();
$.get(window.location.href, function(data)
{
$('#sidebar').replaceWith($(data).find('#sidebar'));
$('#edit-token').replaceWith($(data).find('#edit-token'));
$('body').trigger('dom-update');
});
formDom.parents('.unit').hide();
} }
else else
{ {
alert(data['errorMessage']); alert(data['message']);
formDom.find(':input').attr('readonly', false);
formDom.removeClass('inactive');
} }
} formDom.find(':input').attr('readonly', false);
}; formDom.removeClass('inactive');
},
$.ajax(ajaxData); error: function()
});
$('form.add-comment').submit(function(e)
{
e.preventDefault();
var formDom = $(this);
if (formDom.hasClass('inactive'))
return;
formDom.addClass('inactive');
formDom.find(':input').attr('readonly', true);
var url = formDom.attr('action') + '?json';
var fd = new FormData(formDom[0]);
var preview = false;
$.each(formDom.serializeArray(), function(i, x)
{
if (x.name == 'sender' && x.value == 'preview')
preview = true;
});
var ajaxData =
{
url: url,
data: fd,
processData: false,
contentType: false,
type: 'POST',
success: function(data)
{ {
if (data['success']) alert('Fatal error');
{ formDom.find(':input').attr('readonly', false);
if (preview) formDom.removeClass('inactive');
{
formDom.find('.preview').html(data['textPreview']).show();
formDom.find(':input').attr('readonly', false);
formDom.removeClass('inactive');
}
else
{
window.location.reload();
}
}
else
{
alert(data['errorMessage']);
formDom.find(':input').attr('readonly', false);
formDom.removeClass('inactive');
}
} }
}; };
$.ajax(ajaxData); $.ajax(ajaxData);
}); });
Mousetrap.bind('a', function() { var a = $('#sidebar .left a'); var url = a.attr('href'); if (typeof url !== 'undefined') { a.click(); window.location.href = url; } }, 'keyup');
Mousetrap.bind('d', function() { var a = $('#sidebar .right a'); var url = a.attr('href'); if (typeof url !== 'undefined') { a.click(); window.location.href = url; } }, 'keyup');
Mousetrap.bind('e', function() { $('a.edit-post').trigger('click'); return false; }, 'keyup');
}); });

View File

@ -1,195 +0,0 @@
$(function()
{
var tags = [];
$.getJSON('/tags?json', function(data)
{
tags = data['tags'];
});
var handler = $('#file-handler');
handler.on('dragenter', function(e)
{
$(this).addClass('active');
});
handler.on('dragleave', function(e)
{
$(this).removeClass('active');
});
handler.on('dragover', function(e)
{
e.preventDefault();
});
handler.on('drop', function(e)
{
e.preventDefault();
handleFiles(e.originalEvent.dataTransfer.files);
$(this).trigger('dragleave');
});
handler.on('click', function(e)
{
$(':file').show().focus().trigger('click').hide();
});
$(':file').change(function(e)
{
handleFiles(this.files);
});
$('.post .move-down-trigger, .post .move-up-trigger').on('click', function()
{
var dir = $(this).hasClass('move-down-trigger') ? 'd' : 'u';
var post = $(this).parents('.post');
if (dir == 'u')
post.insertBefore(post.prev('.post'));
else
post.insertAfter(post.next('.post'));
});
$('.post .remove-trigger').on('click', function()
{
$(this).parents('.post').slideUp(function()
{
$(this).remove();
});
if ($('#upload-step2 .post').length == 1)
{
$('#upload-step2').slideUp();
$('#upload-no-posts').slideDown();
}
});
function sendNextPost()
{
var posts = $('#upload-step2 .post');
if (posts.length == 0)
{
uploadFinished();
return;
}
var postDom = posts.first();
var url = postDom.find('form').attr('action') + '?json';
var file = postDom.data('file');
var tags = postDom.find('[name=tags]').val();
var safety = postDom.find('[name=safety]:checked').val();
var source = postDom.find('[name=source]').val();
var fd = new FormData();
fd.append('file', file);
fd.append('tags', tags);
fd.append('safety', safety);
fd.append('source', source);
var ajaxData =
{
url: url,
data: fd,
processData: false,
contentType: false,
dataType: 'json',
type: 'POST',
success: function(data)
{
if (data['success'])
{
postDom.slideUp(function()
{
postDom.remove();
sendNextPost();
});
}
else
{
postDom.find('.alert').html(data['errorHtml']).slideDown();
enableUpload();
}
}
};
$.ajax(ajaxData);
}
function uploadFinished()
{
window.location.href = $('#upload-step2').attr('data-redirect-url');
}
function disableUpload()
{
var theSubmit = $('#the-submit');
theSubmit.addClass('inactive');
var posts = $('#upload-step2 .post');
posts.find(':input').attr('readonly', true);
posts.addClass('inactive');
}
function enableUpload()
{
var theSubmit = $('#the-submit');
theSubmit.removeClass('inactive');
var posts = $('#upload-step2 .post');
posts.removeClass('inactive');
posts.find(':input').attr('readonly', false);
}
$('#the-submit').click(function(e)
{
e.preventDefault();
var theSubmit = $(this);
disableUpload();
sendNextPost();
});
function handleFiles(files)
{
$('#upload-step1').fadeOut(function()
{
for (var i = 0; i < files.length; i ++)
{
var file = files[i];
var postDom = $('#post-template').clone(true);
postDom.find('form').submit(false);
postDom.removeAttr('id');
postDom.data('file', file);
$('.file-name strong', postDom).text(file.name);
$('.posts').append(postDom);
postDom.show();
var tagItOptions =
{
caseSensitive: true,
availableTags: tags,
placeholderText: $('.tags input').attr('placeholder')
};
$('.tags input', postDom).tagit(tagItOptions);
if (!file.type.match('image.*'))
{
continue;
}
var img = postDom.find('img')
var reader = new FileReader();
reader.onload = (function(theFile, img)
{
return function(e)
{
/*img.css('max-width', img.css('width'));
img.css('max-height', img.css('height'));
img.css('width', 'auto');
img.css('height', 'auto');*/
img.css('background-image', 'none');
img.attr('src', e.target.result);
};
})(file, img);
reader.readAsDataURL(file);
}
$('#upload-step2').fadeIn(function()
{
});
});
}
});

26
scripts/find-posts.php Normal file
View File

@ -0,0 +1,26 @@
<?php
require_once __DIR__ . '/../src/core.php';
function usage()
{
echo 'Usage: ' . basename(__FILE__);
echo ' QUERY' . PHP_EOL;
return true;
}
array_shift($argv);
if (empty($argv))
usage() and die;
$query = array_shift($argv);
$posts = Model_Post::getEntities($query, null, null);
foreach ($posts as $post)
{
echo implode("\t",
[
$post->id,
$post->name,
Model_Post::getFullPath($post->name),
$post->mimeType,
]). PHP_EOL;
}

View File

@ -33,21 +33,18 @@ switch ($action)
$func = function($name) use ($dir) $func = function($name) use ($dir)
{ {
echo $name . PHP_EOL; echo $name . PHP_EOL;
static $filesPath = null; $srcPath = Model_Post::getFullPath($name);
if ($filesPath == null) $dstPath = $dir . DS . $name;
$filesPath = configFactory()->main->filesPath; rename($srcPath, $dstPath);
rename($filesPath . DS . $name, $dir . DS . $name);
}; };
break; break;
case '-purge': case '-purge':
$func = function($name) use ($dir) $func = function($name)
{ {
echo $name . PHP_EOL; echo $name . PHP_EOL;
static $filesPath = null; $srcPath = Model_Post::getFullPath($name);
if ($filesPath == null) unlink($srcPath);
$filesPath = configFactory()->main->filesPath;
unlink($filesPath . DS . $name);
}; };
break; break;
@ -62,9 +59,8 @@ foreach (R::findAll('post') as $post)
} }
$names = array_flip($names); $names = array_flip($names);
$config = configFactory(); $config = \Chibi\Registry::getConfig();
$filesPath = $config->main->filesPath; foreach (glob(TextHelper::absolutePath($config->main->filesPath) . DS . '*') as $name)
foreach (glob($filesPath . DS . '*') as $name)
{ {
$name = basename($name); $name = basename($name);
if (!isset($names[$name])) if (!isset($names[$name]))

View File

@ -0,0 +1,47 @@
<?php
require_once __DIR__ . '/../src/core.php';
function usage()
{
echo 'Usage: ' . basename(__FILE__);
echo ' -print|-purge';
return true;
}
array_shift($argv);
if (empty($argv))
usage() and die;
function printUser($user)
{
echo 'ID: ' . $user->id . PHP_EOL;
echo 'Name: ' . $user->name . PHP_EOL;
echo 'E-mail: ' . $user->email_unconfirmed . PHP_EOL;
echo 'Date joined: ' . date('Y-m-d H:i:s', $user->join_date) . PHP_EOL;
echo PHP_EOL;
}
$action = array_shift($argv);
switch ($action)
{
case '-print':
$func = 'printUser';
break;
case '-purge':
$func = function($user)
{
printUser($user);
Model_User::remove($user);
};
break;
default:
die('Unknown action' . PHP_EOL);
}
$rows = R::find('user', 'email_confirmed IS NULL AND DATETIME(join_date) < DATETIME("now", "-21 days")');
foreach ($rows as $user)
{
$func($user);
}

View File

@ -0,0 +1,14 @@
#!/bin/sh
process () {
x="$1";
echo "$x";
convert "$x" -fuzz 7% -trim +repage tmp && mv tmp "$x"
}
while read x; do
process "$x";
done
for x in $@; do
process "$x";
done

View File

@ -1,24 +1,14 @@
<?php <?php
use \Chibi\Database as Database;
class Bootstrap class Bootstrap
{ {
public function attachUser() public function render($callback = null)
{ {
$this->context->loggedIn = false; if ($callback !== null)
if (isset($_SESSION['user-id'])) $callback();
{ else
$this->context->user = R::findOne('user', 'id = ?', [$_SESSION['user-id']]); (new \Chibi\View())->renderFile($this->context->layoutName);
if (!empty($this->context->user))
{
$this->context->loggedIn = true;
}
}
if (empty($this->context->user))
{
$dummy = R::dispense('user');
$dummy->name = 'Anonymous';
$dummy->access_rank = AccessRank::Anonymous;
$this->context->user = $dummy;
}
} }
public function workWrapper($workCallback) public function workWrapper($workCallback)
@ -27,56 +17,55 @@ class Bootstrap
session_start(); session_start();
$this->context->handleExceptions = false; $this->context->handleExceptions = false;
$this->context->title = $this->config->main->title; CustomAssetViewDecorator::setTitle($this->config->main->title);
$this->context->stylesheets =
[
'../lib/jquery-ui/jquery-ui.css',
'core.css',
];
$this->context->scripts =
[
'../lib/jquery/jquery.min.js',
'../lib/jquery-ui/jquery-ui.min.js',
'core.js',
];
$this->context->layoutName = isset($_GET['json']) $this->context->json = isset($_GET['json']);
$this->context->layoutName = $this->context->json
? 'layout-json' ? 'layout-json'
: 'layout-normal'; : 'layout-normal';
$this->context->transport = new StdClass; $this->context->transport = new StdClass;
$this->context->transport->success = null; StatusHelper::init();
$this->attachUser(); AuthController::doLogIn();
if (empty($this->context->route)) if (empty($this->context->route))
{ {
http_response_code(404);
$this->context->viewName = 'error-404'; $this->context->viewName = 'error-404';
(new \Chibi\View())->renderFile($this->context->layoutName); $this->render();
return; return;
} }
$this->context->viewDecorators []= new CustomAssetViewDecorator();
$this->context->viewDecorators []= new \Chibi\PrettyPrintViewDecorator();
try try
{ {
$workCallback(); $this->render($workCallback);
}
catch (\Chibi\MissingViewFileException $e)
{
$this->context->json = true;
$this->context->layoutName = 'layout-json';
$this->render();
} }
catch (SimpleException $e) catch (SimpleException $e)
{ {
$this->context->transport->errorMessage = rtrim($e->getMessage(), '.') . '.'; if ($e instanceof SimpleNotFoundException)
$this->context->transport->errorHtml = TextHelper::parseMarkdown($this->context->transport->errorMessage, true); http_response_code(404);
$this->context->transport->exception = $e; StatusHelper::failure($e->getMessage());
$this->context->transport->success = false;
if (!$this->context->handleExceptions) if (!$this->context->handleExceptions)
$this->context->viewName = 'error-simple'; $this->context->viewName = 'message';
(new \Chibi\View())->renderFile($this->context->layoutName); $this->render();
} }
catch (Exception $e) catch (Exception $e)
{ {
$this->context->transport->errorMessage = rtrim($e->getMessage(), '.') . '.'; StatusHelper::failure($e->getMessage());
$this->context->transport->errorHtml = TextHelper::parseMarkdown($this->context->transport->errorMessage, true);
$this->context->transport->exception = $e; $this->context->transport->exception = $e;
$this->context->transport->success = false; $this->context->transport->queries = Database::getLogs();
$this->context->viewName = 'error-exception'; $this->context->viewName = 'error-exception';
(new \Chibi\View())->renderFile($this->context->layoutName); $this->render();
} }
AuthController::observeWorkFinish();
} }
} }

View File

@ -1,46 +1,81 @@
<?php <?php
class AuthController class AuthController
{ {
private static function redirectAfterLog()
{
if (isset($_SESSION['login-redirect-url']))
{
\Chibi\UrlHelper::forward($_SESSION['login-redirect-url']);
unset($_SESSION['login-redirect-url']);
return;
}
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index'));
}
public static function tryLogin($name, $password)
{
$config = \Chibi\Registry::getConfig();
$context = \Chibi\Registry::getContext();
$dbUser = UserModel::findByNameOrEmail($name, false);
if ($dbUser === null)
throw new SimpleException('Invalid username');
$passwordHash = UserModel::hashPassword($password, $dbUser->passSalt);
if ($passwordHash != $dbUser->passHash)
throw new SimpleException('Invalid password');
if (!$dbUser->staffConfirmed and $config->registration->staffActivation)
throw new SimpleException('Staff hasn\'t confirmed your registration yet');
if ($dbUser->banned)
throw new SimpleException('You are banned');
if ($config->registration->needEmailForRegistering)
PrivilegesHelper::confirmEmail($dbUser);
$context->user = $dbUser;
self::doReLog();
return $dbUser;
}
public static function tryAutoLogin()
{
if (!isset($_COOKIE['auth']))
return;
$token = TextHelper::decrypt($_COOKIE['auth']);
list ($name, $password) = array_map('base64_decode', explode('|', $token));
return self::tryLogin($name, $password);
}
/** /**
* @route /auth/login * @route /auth/login
*/ */
public function loginAction() public function loginAction()
{ {
$this->context->handleExceptions = true; $this->context->handleExceptions = true;
$this->context->stylesheets []= 'auth.css';
$this->context->subTitle = 'authentication form';
//check if already logged in //check if already logged in
if ($this->context->loggedIn) if ($this->context->loggedIn)
{ {
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index')); self::redirectAfterLog();
return; return;
} }
$suppliedName = InputHelper::get('name'); if (InputHelper::get('submit'))
$suppliedPassword = InputHelper::get('password');
if ($suppliedName !== null and $suppliedPassword !== null)
{ {
$dbUser = R::findOne('user', 'name = ?', [$suppliedName]); $suppliedName = InputHelper::get('name');
if ($dbUser === null) $suppliedPassword = InputHelper::get('password');
throw new SimpleException('Invalid username'); $dbUser = self::tryLogin($suppliedName, $suppliedPassword);
$suppliedPasswordHash = Model_User::hashPassword($suppliedPassword, $dbUser->pass_salt); if (InputHelper::get('remember'))
if ($suppliedPasswordHash != $dbUser->pass_hash) {
throw new SimpleException('Invalid password'); $token = implode('|', [base64_encode($suppliedName), base64_encode($suppliedPassword)]);
setcookie('auth', TextHelper::encrypt($token), time() + 365 * 24 * 3600, '/');
if (!$dbUser->staff_confirmed and $this->config->registration->staffActivation) }
throw new SimpleException('Staff hasn\'t confirmed your registration yet'); StatusHelper::success();
self::redirectAfterLog();
if ($dbUser->banned)
throw new SimpleException('You are banned');
if ($this->config->registration->needEmailForRegistering)
PrivilegesHelper::confirmEmail($dbUser);
$_SESSION['user-id'] = $dbUser->id;
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index'));
$this->context->transport->success = true;
} }
} }
@ -50,8 +85,67 @@ class AuthController
public function logoutAction() public function logoutAction()
{ {
$this->context->viewName = null; $this->context->viewName = null;
$this->context->viewName = null; $this->context->layoutName = null;
unset($_SESSION['user-id']); self::doLogOut();
setcookie('auth', false, 0, '/');
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index')); \Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index'));
} }
public static function doLogOut()
{
unset($_SESSION['user']);
}
public static function doLogIn()
{
$context = \Chibi\Registry::getContext();
if (!isset($_SESSION['user']))
{
if (!empty($context->user) and $context->user->id)
{
$dbUser = UserModel::findById($context->user->id);
$context->user->lastLoginDate = time();
UserModel::save($context->user);
$_SESSION['user'] = serialize($dbUser);
}
else
{
$dummy = UserModel::spawn();
$dummy->name = UserModel::getAnonymousName();
$dummy->accessRank = AccessRank::Anonymous;
$_SESSION['user'] = serialize($dummy);
}
}
$context->user = unserialize($_SESSION['user']);
$context->loggedIn = $context->user->accessRank != AccessRank::Anonymous;
if (!$context->loggedIn)
{
try
{
self::tryAutoLogin();
}
catch (Exception $e)
{
}
}
}
public static function doReLog()
{
$context = \Chibi\Registry::getContext();
if ($context->user !== null)
self::doLogOut();
self::doLogIn();
}
public static function observeWorkFinish()
{
if (strpos(\Chibi\HeadersHelper::get('Content-Type'), 'text/html') === false)
return;
$context = \Chibi\Registry::getContext();
if ($context->route->simpleControllerName == 'auth')
return;
$_SESSION['login-redirect-url'] = $context->query;
}
} }

View File

@ -8,47 +8,30 @@ class CommentController
*/ */
public function listAction($page) public function listAction($page)
{ {
$this->context->stylesheets []= 'post-small.css';
$this->context->stylesheets []= 'comment-list.css';
$this->context->stylesheets []= 'comment-small.css';
$this->context->subTitle = 'comments';
if ($this->config->browsing->endlessScrolling)
$this->context->scripts []= 'paginator-endless.js';
$page = intval($page);
$commentsPerPage = intval($this->config->comments->commentsPerPage);
PrivilegesHelper::confirmWithException(Privilege::ListComments); PrivilegesHelper::confirmWithException(Privilege::ListComments);
$buildDbQuery = function($dbQuery) $page = max(1, intval($page));
{ $commentsPerPage = intval($this->config->comments->commentsPerPage);
$dbQuery->from('comment'); $searchQuery = 'comment_min:1 order:comment_date,desc';
$dbQuery->orderBy('comment_date')->desc();
};
$countDbQuery = R::$f->begin(); $posts = PostSearchService::getEntities($searchQuery, $commentsPerPage, $page);
$countDbQuery->select('COUNT(1)')->as('count'); $postCount = PostSearchService::getEntityCount($searchQuery);
$buildDbQuery($countDbQuery); $pageCount = ceil($postCount / $commentsPerPage);
$commentCount = intval($countDbQuery->get('row')['count']); PostModel::preloadTags($posts);
$pageCount = ceil($commentCount / $commentsPerPage); PostModel::preloadComments($posts);
$page = max(1, min($pageCount, $page)); $comments = [];
foreach ($posts as $post)
$comments = array_merge($comments, $post->getComments());
CommentModel::preloadCommenters($comments);
$searchDbQuery = R::$f->begin();
$searchDbQuery->select('comment.*');
$buildDbQuery($searchDbQuery);
$searchDbQuery->limit('?')->put($commentsPerPage);
$searchDbQuery->offset('?')->put(($page - 1) * $commentsPerPage);
$comments = $searchDbQuery->get();
$comments = R::convertToBeans('comment', $comments);
R::preload($comments, ['commenter' => 'user', 'post', 'post.uploader' => 'user']);
$this->context->postGroups = true; $this->context->postGroups = true;
$this->context->transport->posts = $posts;
$this->context->transport->paginator = new StdClass; $this->context->transport->paginator = new StdClass;
$this->context->transport->paginator->page = $page; $this->context->transport->paginator->page = $page;
$this->context->transport->paginator->pageCount = $pageCount; $this->context->transport->paginator->pageCount = $pageCount;
$this->context->transport->paginator->entityCount = $commentCount; $this->context->transport->paginator->entityCount = $postCount;
$this->context->transport->paginator->entities = $comments; $this->context->transport->paginator->entities = $posts;
$this->context->transport->paginator->params = func_get_args(); $this->context->transport->paginator->params = func_get_args();
$this->context->transport->comments = $comments;
} }
@ -63,21 +46,60 @@ class CommentController
if ($this->config->registration->needEmailForCommenting) if ($this->config->registration->needEmailForCommenting)
PrivilegesHelper::confirmEmail($this->context->user); PrivilegesHelper::confirmEmail($this->context->user);
$post = Model_Post::locate($postId); $post = PostModel::findById($postId);
$this->context->transport->post = $post;
$text = InputHelper::get('text'); if (InputHelper::get('submit'))
if (!empty($text))
{ {
$text = Model_Comment::validateText($text); $text = InputHelper::get('text');
$comment = R::dispense('comment'); $text = CommentModel::validateText($text);
$comment->post = $post;
$comment->commenter = $this->context->user; $comment = CommentModel::spawn();
$comment->comment_date = time(); $comment->setPost($post);
if ($this->context->loggedIn)
$comment->setCommenter($this->context->user);
else
$comment->setCommenter(null);
$comment->commentDate = time();
$comment->text = $text; $comment->text = $text;
if (InputHelper::get('sender') != 'preview') if (InputHelper::get('sender') != 'preview')
R::store($comment); {
CommentModel::save($comment);
LogHelper::log('{user} commented on {post}', ['post' => TextHelper::reprPost($post->id)]);
}
$this->context->transport->textPreview = $comment->getText(); $this->context->transport->textPreview = $comment->getText();
$this->context->transport->success = true; StatusHelper::success();
}
}
/**
* @route /comment/{id}/edit
* @validate id [0-9]+
*/
public function editAction($id)
{
$comment = CommentModel::findById($id);
$this->context->transport->comment = $comment;
PrivilegesHelper::confirmWithException(Privilege::EditComment, PrivilegesHelper::getIdentitySubPrivilege($comment->getCommenter()));
if (InputHelper::get('submit'))
{
$text = InputHelper::get('text');
$text = CommentModel::validateText($text);
$comment->text = $text;
if (InputHelper::get('sender') != 'preview')
{
CommentModel::save($comment);
LogHelper::log('{user} edited comment in {post}', ['post' => TextHelper::reprPost($comment->getPost())]);
}
$this->context->transport->textPreview = $comment->getText();
StatusHelper::success();
} }
} }
@ -89,10 +111,12 @@ class CommentController
*/ */
public function deleteAction($id) public function deleteAction($id)
{ {
$comment = Model_Comment::locate($id); $comment = CommentModel::findById($id);
R::preload($comment, ['commenter' => 'user']);
PrivilegesHelper::confirmWithException(Privilege::DeleteComment, PrivilegesHelper::getIdentitySubPrivilege($comment->commenter)); PrivilegesHelper::confirmWithException(Privilege::DeleteComment, PrivilegesHelper::getIdentitySubPrivilege($comment->getCommenter()));
R::trash($comment); CommentModel::remove($comment);
$this->context->transport->success = true;
LogHelper::log('{user} removed comment from {post}', ['post' => TextHelper::reprPost($comment->getPost())]);
StatusHelper::success();
} }
} }

View File

@ -7,48 +7,48 @@ class IndexController
*/ */
public function indexAction() public function indexAction()
{ {
$this->context->subTitle = 'home'; $this->context->transport->postCount = PostModel::getCount();
$this->context->stylesheets []= 'index-index.css';
$this->context->transport->postCount = R::$f->begin()->select('count(1)')->as('count')->from('post')->get('row')['count'];
$featuredPostRotationTime = $this->config->main->featuredPostMaxDays * 24 * 3600; $featuredPost = $this->getFeaturedPost();
if ($featuredPost)
$featuredPostId = Model_Property::get(Model_Property::FeaturedPostId);
$featuredPostUserId = Model_Property::get(Model_Property::FeaturedPostUserId);
$featuredPostDate = Model_Property::get(Model_Property::FeaturedPostDate);
if (!$featuredPostId or $featuredPostDate + $featuredPostRotationTime < time())
{ {
$featuredPostId = R::$f->begin()
->select('id')
->from('post')
->where('type = ?')->put(PostType::Image)
->and('safety = ?')->put(PostSafety::Safe)
->orderBy('random()')
->desc()
->get('row')['id'];
$featuredPostUserId = null;
$featuredPostDate = time();
Model_Property::set(Model_Property::FeaturedPostId, $featuredPostId);
Model_Property::set(Model_Property::FeaturedPostUserId, $featuredPostUserId);
Model_Property::set(Model_Property::FeaturedPostDate, $featuredPostDate);
}
if ($featuredPostId !== null)
{
$featuredPost = Model_Post::locate($featuredPostId);
R::preload($featuredPost, ['user', 'comment', 'favoritee']);
$featuredPostUser = R::findOne('user', 'id = ?', [$featuredPostUserId]);
$this->context->featuredPost = $featuredPost; $this->context->featuredPost = $featuredPost;
$this->context->featuredPostUser = $featuredPostUser; $this->context->featuredPostDate = PropertyModel::get(PropertyModel::FeaturedPostDate);
$this->context->featuredPostDate = $featuredPostDate; $this->context->featuredPostUser = UserModel::findByNameOrEmail(PropertyModel::get(PropertyModel::FeaturedPostUserName), false);
} }
} }
/** /**
* @route /help * @route /help
* @route /help/{tab}
*/ */
public function helpAction() public function helpAction($tab = null)
{ {
$this->context->subTitle = 'help'; if (empty($this->config->help->paths) or empty($this->config->help->title))
throw new SimpleException('Help is disabled');
$tab = $tab ?: array_keys($this->config->help->subTitles)[0];
if (!isset($this->config->help->paths[$tab]))
throw new SimpleException('Invalid tab');
$this->context->path = TextHelper::absolutePath($this->config->help->paths[$tab]);
$this->context->tab = $tab;
}
private function getFeaturedPost()
{
$featuredPostRotationTime = $this->config->misc->featuredPostMaxDays * 24 * 3600;
$featuredPostId = PropertyModel::get(PropertyModel::FeaturedPostId);
$featuredPostDate = PropertyModel::get(PropertyModel::FeaturedPostDate);
//check if too old
if (!$featuredPostId or $featuredPostDate + $featuredPostRotationTime < time())
return $this->featureNewPost();
//check if post was deleted
$featuredPost = PostModel::findById($featuredPostId, false);
if (!$featuredPost)
return PropertyModel::featureNewPost();
return $featuredPost;
} }
} }

View File

@ -0,0 +1,94 @@
<?php
class LogController
{
/**
* @route /logs
*/
public function listAction()
{
PrivilegesHelper::confirmWithException(Privilege::ListLogs);
$path = TextHelper::absolutePath($this->config->main->logsPath);
$logs = [];
foreach (glob($path . DS . '*.log') as $log)
$logs []= basename($log);
usort($logs, function($a, $b)
{
return strnatcasecmp($b, $a); //reverse natcasesort
});
$this->context->transport->logs = $logs;
}
/**
* @route /log/{name}
* @route /log/{name}/{page}
* @route /log/{name}/{page}/{filter}
* @validate name [0-9a-zA-Z._-]+
* @validate page \d*
* @validate filter .*
*/
public function viewAction($name, $page = 1, $filter = '')
{
//redirect requests in form of ?query=... to canonical address
$formQuery = InputHelper::get('query');
if ($formQuery !== null)
{
\Chibi\UrlHelper::forward(
\Chibi\UrlHelper::route(
'log',
'view',
[
'name' => $name,
'filter' => $formQuery,
'page' => 1
]));
return;
}
PrivilegesHelper::confirmWithException(Privilege::ViewLog);
//parse input
$page = max(1, intval($page));
$name = str_replace(['/', '\\'], '', $name); //paranoia mode
$path = TextHelper::absolutePath($this->config->main->logsPath . DS . $name);
if (!file_exists($path))
throw new SimpleNotFoundException('Specified log doesn\'t exist');
//load lines
$lines = file_get_contents($path);
$lines = explode(PHP_EOL, str_replace(["\r", "\n"], PHP_EOL, $lines));
$lines = array_reverse($lines);
if (!empty($filter))
$lines = array_filter($lines, function($line) use ($filter) { return stripos($line, $filter) !== false; });
$lineCount = count($lines);
$logsPerPage = intval($this->config->browsing->logsPerPage);
$pageCount = ceil($lineCount / $logsPerPage);
$page = min($pageCount, $page);
$lines = array_slice($lines, ($page - 1) * $logsPerPage, $logsPerPage);
//stylize important lines
foreach ($lines as &$line)
if (strpos($line, 'flag') !== false)
$line = '**' . $line . '**';
unset($line);
$lines = join(PHP_EOL, $lines);
$lines = TextHelper::parseMarkdown($lines, true);
$lines = trim($lines);
$this->context->transport->paginator = new StdClass;
$this->context->transport->paginator->page = $page;
$this->context->transport->paginator->pageCount = $pageCount;
$this->context->transport->paginator->entityCount = $lineCount;
$this->context->transport->paginator->entities = $lines;
$this->context->transport->lines = $lines;
$this->context->transport->filter = $filter;
$this->context->transport->name = $name;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,77 +3,121 @@ class TagController
{ {
/** /**
* @route /tags * @route /tags
* @route /tags/{page}
* @route /tags/{filter}
* @route /tags/{filter}/{page}
* @validate filter [a-zA-Z\32:,_-]+
* @validate page \d*
*/ */
public function listAction() public function listAction($filter = null, $page = 1)
{ {
$this->context->stylesheets []= 'tag-list.css'; $this->context->viewName = 'tag-list-wrapper';
$this->context->subTitle = 'tags';
PrivilegesHelper::confirmWithException(Privilege::ListTags); PrivilegesHelper::confirmWithException(Privilege::ListTags);
$dbQuery = R::$f->begin(); $suppliedFilter = $filter ?: InputHelper::get('filter') ?: 'order:alpha,asc';
$dbQuery->select('tag.name, COUNT(1) AS count'); $page = max(1, intval($page));
$dbQuery->from('tag'); $tagsPerPage = intval($this->config->browsing->tagsPerPage);
$dbQuery->innerJoin('post_tag');
$dbQuery->on('tag.id = post_tag.tag_id');
$dbQuery->groupBy('tag.id');
$dbQuery->orderBy('LOWER(tag.name)')->asc();
$rows = $dbQuery->get();
$tags = []; $tags = TagSearchService::getEntitiesRows($suppliedFilter, $tagsPerPage, $page);
$tagDistribution = []; $tagCount = TagSearchService::getEntityCount($suppliedFilter);
foreach ($rows as $row) $pageCount = ceil($tagCount / $tagsPerPage);
{ $page = min($pageCount, $page);
$tags []= strval($row['name']);
$tagDistribution[$row['name']] = intval($row['count']);
}
$this->context->filter = $suppliedFilter;
$this->context->transport->tags = $tags; $this->context->transport->tags = $tags;
$this->context->transport->tagDistribution = $tagDistribution;
if ($this->context->json)
{
$this->context->transport->tags = array_values(array_map(function($tag) {
return ['name' => $tag['name'], 'count' => $tag['post_count']];
}, $this->context->transport->tags));
}
else
{
$this->context->highestUsage = TagSearchService::getMostUsedTag()['post_count'];
$this->context->transport->paginator = new StdClass;
$this->context->transport->paginator->page = $page;
$this->context->transport->paginator->pageCount = $pageCount;
$this->context->transport->paginator->entityCount = $tagCount;
$this->context->transport->paginator->entities = $tags;
}
} }
/** /**
* @route /tags/merge * @route /tag/merge
*/ */
public function mergeAction() public function mergeAction()
{ {
$this->context->viewName = 'tag-list-wrapper';
$this->context->handleExceptions = true;
PrivilegesHelper::confirmWithException(Privilege::MergeTags); PrivilegesHelper::confirmWithException(Privilege::MergeTags);
$sourceTag = Model_Tag::locate(InputHelper::get('source-tag')); if (InputHelper::get('submit'))
$targetTag = Model_Tag::locate(InputHelper::get('target-tag'));
R::preload($sourceTag, 'post');
foreach ($sourceTag->sharedPost as $post)
{ {
foreach ($post->sharedTag as $key => $postTag) TagModel::removeUnused();
if ($postTag->id == $sourceTag->id)
unset($post->sharedTag[$key]); $suppliedSourceTag = InputHelper::get('source-tag');
$post->sharedTag []= $targetTag; $suppliedSourceTag = TagModel::validateTag($suppliedSourceTag);
R::store($post);
$suppliedTargetTag = InputHelper::get('target-tag');
$suppliedTargetTag = TagModel::validateTag($suppliedTargetTag);
TagModel::merge($suppliedSourceTag, $suppliedTargetTag);
LogHelper::log('{user} merged {source} with {target}', ['source' => TextHelper::reprTag($suppliedSourceTag), 'target' => TextHelper::reprTag($suppliedTargetTag)]);
StatusHelper::success('Tags merged successfully.');
} }
R::trash($sourceTag);
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('tag', 'list'));
$this->view->context->success = true;
} }
/** /**
* @route /tags/rename * @route /tag/rename
*/ */
public function renameAction() public function renameAction()
{ {
$this->context->viewName = 'tag-list-wrapper';
$this->context->handleExceptions = true;
PrivilegesHelper::confirmWithException(Privilege::MergeTags); PrivilegesHelper::confirmWithException(Privilege::MergeTags);
if (InputHelper::get('submit'))
{
TagModel::removeUnused();
$suppliedSourceTag = InputHelper::get('source-tag'); $suppliedSourceTag = InputHelper::get('source-tag');
$suppliedSourceTag = Model_Tag::validateTag($suppliedSourceTag); $suppliedSourceTag = TagModel::validateTag($suppliedSourceTag);
$suppliedTargetTag = InputHelper::get('target-tag'); $suppliedTargetTag = InputHelper::get('target-tag');
$suppliedTargetTag = Model_Tag::validateTag($suppliedTargetTag); $suppliedTargetTag = TagModel::validateTag($suppliedTargetTag);
$sourceTag = Model_Tag::locate($suppliedSourceTag); TagModel::rename($suppliedSourceTag, $suppliedTargetTag);
$sourceTag->name = $suppliedTargetTag;
R::store($sourceTag);
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('tag', 'list')); LogHelper::log('{user} renamed {source} to {target}', ['source' => TextHelper::reprTag($suppliedSourceTag), 'target' => TextHelper::reprTag($suppliedTargetTag)]);
$this->context->transport->success = true; StatusHelper::success('Tag renamed successfully.');
}
}
/**
* @route /mass-tag-redirect
*/
public function massTagRedirectAction()
{
$this->context->viewName = 'tag-list-wrapper';
PrivilegesHelper::confirmWithException(Privilege::MassTag);
if (InputHelper::get('submit'))
{
$suppliedOldPage = intval(InputHelper::get('old-page'));
$suppliedOldQuery = InputHelper::get('old-query');
$suppliedQuery = InputHelper::get('query');
$suppliedTag = InputHelper::get('tag');
$params = [
'source' => 'mass-tag',
'query' => $suppliedQuery ?: ' ',
'additionalInfo' => $suppliedTag ? TagModel::validateTag($suppliedTag) : '',
];
if ($suppliedOldPage != 0 and $suppliedOldQuery == $suppliedQuery)
$params['page'] = $suppliedOldPage;
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('post', 'list', $params));
}
} }
} }

View File

@ -1,27 +1,47 @@
<?php <?php
class UserController class UserController
{ {
private static function sendEmailConfirmation(&$user) private function loadUserView($user)
{ {
$regConfig = \Chibi\Registry::getConfig()->registration; $flagged = in_array(TextHelper::reprUser($user), SessionHelper::get('flagged', []));
$this->context->flagged = $flagged;
$this->context->transport->user = $user;
$this->context->handleExceptions = true;
$this->context->viewName = 'user-view';
}
if (!$regConfig->confirmationEmailEnabled) private static function sendTokenizedEmail(
{ $user,
$user->email_confirmed = $user->email_unconfirmed; $body,
$user->email_unconfirmed = null; $subject,
return; $senderName,
} $senderEmail,
$recipientEmail,
$linkActionName)
{
//prepare unique user token
$token = TokenModel::spawn();
$token->setUser($user);
$token->token = TokenModel::forgeUnusedToken();
$token->used = false;
$token->expires = null;
TokenModel::save($token);
\Chibi\Registry::getContext()->mailSent = true; \Chibi\Registry::getContext()->mailSent = true;
$tokens = []; $tokens = [];
$tokens['host'] = $_SERVER['HTTP_HOST']; $tokens['host'] = $_SERVER['HTTP_HOST'];
$tokens['link'] = \Chibi\UrlHelper::route('user', 'activation', ['token' => $user->email_token]); $tokens['token'] = $token->token; //gosh this code looks so silly
$tokens['nl'] = PHP_EOL;
if ($linkActionName !== null)
$tokens['link'] = \Chibi\UrlHelper::route('user', $linkActionName, ['token' => $token->token]);
$body = wordwrap(TextHelper::replaceTokens($regConfig->confirmationEmailBody, $tokens), 70); $body = wordwrap(TextHelper::replaceTokens($body, $tokens), 70);
$subject = TextHelper::replaceTokens($regConfig->confirmationEmailSubject, $tokens); $subject = TextHelper::replaceTokens($subject, $tokens);
$senderName = TextHelper::replaceTokens($regConfig->confirmationEmailSenderName, $tokens); $senderName = TextHelper::replaceTokens($senderName, $tokens);
$senderEmail = TextHelper::replaceTokens($regConfig->confirmationEmailSenderEmail, $tokens); $senderEmail = TextHelper::replaceTokens($senderEmail, $tokens);
$recipientEmail = $user->email_unconfirmed;
if (empty($recipientEmail))
throw new SimpleException('Destination e-mail address was not found');
$headers = []; $headers = [];
$headers []= sprintf('MIME-Version: 1.0'); $headers []= sprintf('MIME-Version: 1.0');
@ -35,8 +55,44 @@ class UserController
$headers []= sprintf('Content-Type: text/plain; charset=utf-8', $subject); $headers []= sprintf('Content-Type: text/plain; charset=utf-8', $subject);
$headers []= sprintf('X-Mailer: PHP/%s', phpversion()); $headers []= sprintf('X-Mailer: PHP/%s', phpversion());
$headers []= sprintf('X-Originating-IP: %s', $_SERVER['SERVER_ADDR']); $headers []= sprintf('X-Originating-IP: %s', $_SERVER['SERVER_ADDR']);
$subject = '=?UTF-8?B?' . base64_encode($subject) . '?='; $encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
mail($recipientEmail, $subject, $body, implode("\r\n", $headers), '-f' . $senderEmail); mail($recipientEmail, $encodedSubject, $body, implode("\r\n", $headers), '-f' . $senderEmail);
LogHelper::log('Sending e-mail with subject "{subject}" to {mail}', ['subject' => $subject, 'mail' => $recipientEmail]);
}
private static function sendEmailChangeConfirmation($user)
{
$regConfig = \Chibi\Registry::getConfig()->registration;
if (!$regConfig->confirmationEmailEnabled)
{
$user->emailConfirmed = $user->emailUnconfirmed;
$user->emailUnconfirmed = null;
return;
}
return self::sendTokenizedEmail(
$user,
$regConfig->confirmationEmailBody,
$regConfig->confirmationEmailSubject,
$regConfig->confirmationEmailSenderName,
$regConfig->confirmationEmailSenderEmail,
$user->emailUnconfirmed,
'activation');
}
private static function sendPasswordResetConfirmation($user)
{
$regConfig = \Chibi\Registry::getConfig()->registration;
return self::sendTokenizedEmail(
$user,
$regConfig->passwordResetEmailBody,
$regConfig->passwordResetEmailSubject,
$regConfig->passwordResetEmailSenderName,
$regConfig->passwordResetEmailSenderEmail,
$user->emailConfirmed,
'password-reset');
} }
@ -44,78 +100,58 @@ class UserController
/** /**
* @route /users * @route /users
* @route /users/{page} * @route /users/{page}
* @route /users/{sortStyle} * @route /users/{filter}
* @route /users/{sortStyle}/{page} * @route /users/{filter}/{page}
* @validate sortStyle alpha|alpha,asc|alpha,desc|date,asc|date,desc|pending * @validate filter [a-zA-Z\32:,_-]+
* @validate page [0-9]+ * @validate page [0-9]+
*/ */
public function listAction($sortStyle, $page) public function listAction($filter, $page)
{ {
$this->context->stylesheets []= 'user-list.css';
$this->context->stylesheets []= 'paginator.css';
if ($this->config->browsing->endlessScrolling)
$this->context->scripts []= 'paginator-endless.js';
$page = intval($page);
$usersPerPage = intval($this->config->browsing->usersPerPage);
$this->context->subTitle = 'users';
PrivilegesHelper::confirmWithException(Privilege::ListUsers); PrivilegesHelper::confirmWithException(Privilege::ListUsers);
if ($sortStyle == '' or $sortStyle == 'alpha') $suppliedFilter = $filter ?: InputHelper::get('filter') ?: 'order:alpha,asc';
$sortStyle = 'alpha,asc'; $page = max(1, intval($page));
if ($sortStyle == 'date') $usersPerPage = intval($this->config->browsing->usersPerPage);
$sortStyle = 'date,asc';
$buildDbQuery = function($dbQuery, $sortStyle) $users = UserSearchService::getEntities($suppliedFilter, $usersPerPage, $page);
{ $userCount = UserSearchService::getEntityCount($suppliedFilter);
$dbQuery->from('user');
switch ($sortStyle)
{
case 'alpha,asc':
$dbQuery->orderBy('name')->asc();
break;
case 'alpha,desc':
$dbQuery->orderBy('name')->desc();
break;
case 'date,asc':
$dbQuery->orderBy('join_date')->asc();
break;
case 'date,desc':
$dbQuery->orderBy('join_date')->desc();
break;
case 'pending':
$dbQuery->where('staff_confirmed IS NULL');
$dbQuery->or('staff_confirmed = 0');
break;
default:
throw new SimpleException('Unknown sort style');
}
};
$countDbQuery = R::$f->begin();
$countDbQuery->select('COUNT(1)')->as('count');
$buildDbQuery($countDbQuery, $sortStyle);
$userCount = intval($countDbQuery->get('row')['count']);
$pageCount = ceil($userCount / $usersPerPage); $pageCount = ceil($userCount / $usersPerPage);
$page = max(1, min($pageCount, $page)); $page = min($pageCount, $page);
$searchDbQuery = R::$f->begin(); $this->context->filter = $suppliedFilter;
$searchDbQuery->select('user.*'); $this->context->transport->users = $users;
$buildDbQuery($searchDbQuery, $sortStyle);
$searchDbQuery->limit('?')->put($usersPerPage);
$searchDbQuery->offset('?')->put(($page - 1) * $usersPerPage);
$users = $searchDbQuery->get();
$users = R::convertToBeans('user', $users);
$this->context->sortStyle = $sortStyle;
$this->context->transport->paginator = new StdClass; $this->context->transport->paginator = new StdClass;
$this->context->transport->paginator->page = $page; $this->context->transport->paginator->page = $page;
$this->context->transport->paginator->pageCount = $pageCount; $this->context->transport->paginator->pageCount = $pageCount;
$this->context->transport->paginator->entityCount = $userCount; $this->context->transport->paginator->entityCount = $userCount;
$this->context->transport->paginator->entities = $users; $this->context->transport->paginator->entities = $users;
$this->context->transport->paginator->params = func_get_args(); $this->context->transport->paginator->params = func_get_args();
$this->context->transport->users = $users; }
/**
* @route /user/{name}/flag
* @validate name [^\/]+
*/
public function flagAction($name)
{
$user = UserModel::findByNameOrEmail($name);
PrivilegesHelper::confirmWithException(Privilege::FlagUser, PrivilegesHelper::getIdentitySubPrivilege($user));
if (InputHelper::get('submit'))
{
$key = TextHelper::reprUser($user);
$flagged = SessionHelper::get('flagged', []);
if (in_array($key, $flagged))
throw new SimpleException('You already flagged this user');
$flagged []= $key;
SessionHelper::set('flagged', $flagged);
LogHelper::log('{user} flagged {subject} for moderator attention', ['subject' => TextHelper::reprUser($user)]);
StatusHelper::success();
}
} }
@ -126,84 +162,130 @@ class UserController
*/ */
public function banAction($name) public function banAction($name)
{ {
$user = Model_User::locate($name); $user = UserModel::findByNameOrEmail($name);
PrivilegesHelper::confirmWithException(Privilege::BanUser, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::BanUser, PrivilegesHelper::getIdentitySubPrivilege($user));
$user->banned = true;
R::store($user); if (InputHelper::get('submit'))
$this->context->transport->success = true; {
$user->banned = true;
UserModel::save($user);
LogHelper::log('{user} banned {subject}', ['subject' => TextHelper::reprUser($user)]);
StatusHelper::success();
}
} }
/** /**
* @route /post/{name}/unban * @route /post/{name}/unban
* @validate name [^\/]+ * @validate name [^\/]+
*/ */
public function unbanAction($name) public function unbanAction($name)
{ {
$user = Model_User::locate($name); $user = UserModel::findByNameOrEmail($name);
PrivilegesHelper::confirmWithException(Privilege::BanUser, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::BanUser, PrivilegesHelper::getIdentitySubPrivilege($user));
$user->banned = false;
R::store($user); if (InputHelper::get('submit'))
$this->context->transport->success = true; {
$user->banned = false;
UserModel::save($user);
LogHelper::log('{user} unbanned {subject}', ['subject' => TextHelper::reprUser($user)]);
StatusHelper::success();
}
} }
/** /**
* @route /post/{name}/accept-registration * @route /post/{name}/accept-registration
* @validate name [^\/]+ * @validate name [^\/]+
*/ */
public function acceptRegistrationAction($name) public function acceptRegistrationAction($name)
{ {
$user = Model_User::locate($name); $user = UserModel::findByNameOrEmail($name);
PrivilegesHelper::confirmWithException(Privilege::AcceptUserRegistration); PrivilegesHelper::confirmWithException(Privilege::AcceptUserRegistration);
$user->staff_confirmed = true; if (InputHelper::get('submit'))
R::store($user); {
$this->context->transport->success = true; $user->staffConfirmed = true;
UserModel::save($user);
LogHelper::log('{user} confirmed {subject}\'s account', ['subject' => TextHelper::reprUser($user)]);
StatusHelper::success();
}
} }
/** /**
* @route /user/{name}/delete * @route /user/{name}/delete
* @validate name [^\/]+ * @validate name [^\/]+
*/ */
public function deleteAction($name) public function deleteAction($name)
{ {
$user = Model_User::locate($name); $user = UserModel::findByNameOrEmail($name);
PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user));
PrivilegesHelper::confirmWithException(Privilege::DeleteUser, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::DeleteUser, PrivilegesHelper::getIdentitySubPrivilege($user));
$this->context->handleExceptions = true; $this->loadUserView($user);
$this->context->transport->user = $user;
$this->context->transport->tab = 'delete'; $this->context->transport->tab = 'delete';
$this->context->viewName = 'user-view';
$this->context->stylesheets []= 'user-view.css';
$this->context->subTitle = $name;
$this->context->suppliedCurrentPassword = $suppliedCurrentPassword = InputHelper::get('current-password'); $this->context->suppliedCurrentPassword = $suppliedCurrentPassword = InputHelper::get('current-password');
if (InputHelper::get('remove')) if (InputHelper::get('submit'))
{ {
$name = $user->name;
if ($this->context->user->id == $user->id) if ($this->context->user->id == $user->id)
{ {
$suppliedPasswordHash = Model_User::hashPassword($suppliedCurrentPassword, $user->pass_salt); $suppliedPasswordHash = UserModel::hashPassword($suppliedCurrentPassword, $user->passSalt);
if ($suppliedPasswordHash != $user->pass_hash) if ($suppliedPasswordHash != $user->passHash)
throw new SimpleException('Must supply valid password'); throw new SimpleException('Must supply valid password');
} }
foreach ($user->alias('commenter')->ownComment as $comment)
{ $oldId = $user->id;
$comment->commenter = null; UserModel::remove($user);
R::store($comment); if ($oldId == $this->context->user->id)
} AuthController::doLogOut();
foreach ($user->alias('uploader')->ownPost as $post)
{
$post->uploader = null;
R::store($post);
}
$user->ownFavoritee = [];
R::store($user);
R::trash($user);
\Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index')); \Chibi\UrlHelper::forward(\Chibi\UrlHelper::route('index', 'index'));
$this->context->transport->success = true; LogHelper::log('{user} removed {subject}\'s account', ['subject' => TextHelper::reprUser($name)]);
StatusHelper::success();
}
}
/**
* @route /user/{name}/settings
* @validate name [^\/]+
*/
public function settingsAction($name)
{
$user = UserModel::findByNameOrEmail($name);
PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user));
PrivilegesHelper::confirmWithException(Privilege::ChangeUserSettings, PrivilegesHelper::getIdentitySubPrivilege($user));
$this->loadUserView($user);
$this->context->transport->tab = 'settings';
if (InputHelper::get('submit'))
{
$suppliedSafety = InputHelper::get('safety');
if (!is_array($suppliedSafety))
$suppliedSafety = [];
foreach (PostSafety::getAll() as $safety)
$user->enableSafety($safety, in_array($safety, $suppliedSafety));
$user->enableEndlessScrolling(InputHelper::get('endless-scrolling'));
$user->enablePostTagTitles(InputHelper::get('post-tag-titles'));
$user->enableHidingDislikedPosts(InputHelper::get('hide-disliked-posts'));
if ($user->accessRank != AccessRank::Anonymous)
UserModel::save($user);
if ($user->id == $this->context->user->id)
$this->context->user = $user;
AuthController::doReLog();
StatusHelper::success('Browsing settings updated!');
} }
} }
@ -217,17 +299,11 @@ class UserController
{ {
try try
{ {
$user = UserModel::findByNameOrEmail($name);
$user = Model_User::locate($name);
$edited = false;
PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user));
$this->context->handleExceptions = true; $this->loadUserView($user);
$this->context->transport->user = $user;
$this->context->transport->tab = 'edit'; $this->context->transport->tab = 'edit';
$this->context->viewName = 'user-view';
$this->context->stylesheets []= 'user-view.css';
$this->context->subTitle = $name;
$this->context->suppliedCurrentPassword = $suppliedCurrentPassword = InputHelper::get('current-password'); $this->context->suppliedCurrentPassword = $suppliedCurrentPassword = InputHelper::get('current-password');
$this->context->suppliedName = $suppliedName = InputHelper::get('name'); $this->context->suppliedName = $suppliedName = InputHelper::get('name');
@ -235,67 +311,82 @@ class UserController
$this->context->suppliedPassword2 = $suppliedPassword2 = InputHelper::get('password2'); $this->context->suppliedPassword2 = $suppliedPassword2 = InputHelper::get('password2');
$this->context->suppliedEmail = $suppliedEmail = InputHelper::get('email'); $this->context->suppliedEmail = $suppliedEmail = InputHelper::get('email');
$this->context->suppliedAccessRank = $suppliedAccessRank = InputHelper::get('access-rank'); $this->context->suppliedAccessRank = $suppliedAccessRank = InputHelper::get('access-rank');
$currentPasswordHash = $user->pass_hash; $currentPasswordHash = $user->passHash;
if ($suppliedName != '' and $suppliedName != $user->name) if (InputHelper::get('submit'))
{ {
PrivilegesHelper::confirmWithException(Privilege::ChangeUserName, PrivilegesHelper::getIdentitySubPrivilege($user)); $confirmMail = false;
$suppliedName = Model_User::validateUserName($suppliedName); LogHelper::bufferChanges();
$user->name = $suppliedName;
$edited = true;
}
if ($suppliedPassword1 != '') if ($suppliedName != '' and $suppliedName != $user->name)
{ {
PrivilegesHelper::confirmWithException(Privilege::ChangeUserPassword, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::ChangeUserName, PrivilegesHelper::getIdentitySubPrivilege($user));
if ($suppliedPassword1 != $suppliedPassword2) $suppliedName = UserModel::validateUserName($suppliedName);
throw new SimpleException('Specified passwords must be the same'); $oldName = $user->name;
$suppliedPassword = Model_User::validatePassword($suppliedPassword1); $user->name = $suppliedName;
$user->pass_hash = Model_User::hashPassword($suppliedPassword, $user->pass_salt); LogHelper::log('{user} renamed {old} to {new}', ['old' => TextHelper::reprUser($oldName), 'new' => TextHelper::reprUser($suppliedName)]);
$edited = true; }
}
if ($suppliedPassword1 != '')
{
PrivilegesHelper::confirmWithException(Privilege::ChangeUserPassword, PrivilegesHelper::getIdentitySubPrivilege($user));
if ($suppliedPassword1 != $suppliedPassword2)
throw new SimpleException('Specified passwords must be the same');
$suppliedPassword = UserModel::validatePassword($suppliedPassword1);
$user->passHash = UserModel::hashPassword($suppliedPassword, $user->passSalt);
LogHelper::log('{user} changed {subject}\'s password', ['subject' => TextHelper::reprUser($user)]);
}
if ($suppliedEmail != '' and $suppliedEmail != $user->emailConfirmed)
{
PrivilegesHelper::confirmWithException(Privilege::ChangeUserEmail, PrivilegesHelper::getIdentitySubPrivilege($user));
$suppliedEmail = UserModel::validateEmail($suppliedEmail);
if ($this->context->user->id == $user->id)
{
$user->emailUnconfirmed = $suppliedEmail;
if (!empty($user->emailUnconfirmed))
$confirmMail = true;
LogHelper::log('{user} changed e-mail to {mail}', ['mail' => $suppliedEmail]);
}
else
{
$user->emailUnconfirmed = null;
$user->emailConfirmed = $suppliedEmail;
LogHelper::log('{user} changed {subject}\'s e-mail to {mail}', ['subject' => TextHelper::reprUser($user), 'mail' => $suppliedEmail]);
}
}
if ($suppliedAccessRank != '' and $suppliedAccessRank != $user->accessRank)
{
PrivilegesHelper::confirmWithException(Privilege::ChangeUserAccessRank, PrivilegesHelper::getIdentitySubPrivilege($user));
$suppliedAccessRank = UserModel::validateAccessRank($suppliedAccessRank);
$user->accessRank = $suppliedAccessRank;
LogHelper::log('{user} changed {subject}\'s access rank to {rank}', ['subject' => TextHelper::reprUser($user), 'rank' => AccessRank::toString($suppliedAccessRank)]);
}
if ($suppliedEmail != '' and $suppliedEmail != $user->email_confirmed)
{
PrivilegesHelper::confirmWithException(Privilege::ChangeUserEmail, PrivilegesHelper::getIdentitySubPrivilege($user));
$suppliedEmail = Model_User::validateEmail($suppliedEmail);
if ($this->context->user->id == $user->id) if ($this->context->user->id == $user->id)
{ {
$user->email_unconfirmed = $suppliedEmail; $suppliedPasswordHash = UserModel::hashPassword($suppliedCurrentPassword, $user->passSalt);
if (!empty($user->email_unconfirmed))
self::sendEmailConfirmation($user);
}
else
{
$user->email_confirmed = $suppliedEmail;
}
$edited = true;
}
if ($suppliedAccessRank != '' and $suppliedAccessRank != $user->access_rank)
{
PrivilegesHelper::confirmWithException(Privilege::ChangeUserAccessRank, PrivilegesHelper::getIdentitySubPrivilege($user));
$suppliedAccessRank = Model_User::validateAccessRank($suppliedAccessRank);
$user->access_rank = $suppliedAccessRank;
$edited = true;
}
if ($edited)
{
if ($this->context->user->id == $user->id)
{
$suppliedPasswordHash = Model_User::hashPassword($suppliedCurrentPassword, $user->pass_salt);
if ($suppliedPasswordHash != $currentPasswordHash) if ($suppliedPasswordHash != $currentPasswordHash)
throw new SimpleException('Must supply valid current password'); throw new SimpleException('Must supply valid current password');
} }
R::store($user); UserModel::save($user);
$this->context->transport->success = true; if ($this->context->user->id == $user->id)
} AuthController::doReLog();
if ($confirmMail)
self::sendEmailChangeConfirmation($user);
LogHelper::flush();
$message = 'Account settings updated!';
if ($confirmMail)
$message .= ' You will be sent an e-mail address confirmation message soon.';
StatusHelper::success($message);
}
} }
catch (Exception $e) catch (Exception $e)
{ {
$this->context->transport->user = Model_User::locate($name); $this->context->transport->user = UserModel::findByNameOrEmail($name);
throw $e; throw $e;
} }
} }
@ -303,93 +394,40 @@ class UserController
/** /**
* @route /user/{name} * @route /user/{name}/{tab}
* @route /user/{name}/{tab}/{page} * @route /user/{name}/{tab}/{page}
* @validate name [^\/]+ * @validate name [^\/]+
* @validate tab favs|uploads * @validate tab favs|uploads
* @validate page \d* * @validate page \d*
*/ */
public function viewAction($name, $tab, $page) public function viewAction($name, $tab = 'favs', $page)
{ {
$postsPerPage = intval($this->config->browsing->postsPerPage); $postsPerPage = intval($this->config->browsing->postsPerPage);
$user = Model_User::locate($name); $user = UserModel::findByNameOrEmail($name);
if ($tab === null) if ($tab === null)
$tab = 'favs'; $tab = 'favs';
if ($page === null) if ($page === null)
$page = 1; $page = 1;
PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user)); PrivilegesHelper::confirmWithException(Privilege::ViewUser, PrivilegesHelper::getIdentitySubPrivilege($user));
$this->context->stylesheets []= 'user-view.css'; $this->loadUserView($user);
$this->context->stylesheets []= 'post-list.css';
$this->context->stylesheets []= 'post-small.css';
$this->context->stylesheets []= 'paginator.css';
if ($this->config->browsing->endlessScrolling)
$this->context->scripts []= 'paginator-endless.js';
$this->context->subTitle = $name;
$buildDbQuery = function($dbQuery, $user, $tab) $query = '';
{ if ($tab == 'uploads')
$dbQuery->from('post'); $query = 'submit:' . $user->name;
elseif ($tab == 'favs')
$query = 'fav:' . $user->name;
else
throw new SimpleException('Wrong tab');
$page = max(1, $page);
/* safety */ $posts = PostSearchService::getEntities($query, $postsPerPage, $page);
$allowedSafety = array_filter(PostSafety::getAll(), function($safety) $postCount = PostSearchService::getEntityCount($query, $postsPerPage, $page);
{
return PrivilegesHelper::confirm(Privilege::ListPosts, PostSafety::toString($safety)) and
$this->context->user->hasEnabledSafety($safety);
});
$dbQuery->where('safety IN (' . R::genSlots($allowedSafety) . ')');
foreach ($allowedSafety as $s)
$dbQuery->put($s);
/* hidden */
if (!PrivilegesHelper::confirm(Privilege::ListPosts, 'hidden'))
$dbQuery->andNot('hidden');
/* tab */
switch ($tab)
{
case 'uploads':
$dbQuery
->and('uploader_id = ?')
->put($user->id);
break;
case 'favs':
$dbQuery
->and()
->exists()
->open()
->select('1')
->from('favoritee')
->where('post_id = post.id')
->and('favoritee.user_id = ?')
->put($user->id)
->close();
break;
}
};
$countDbQuery = R::$f->begin()->select('COUNT(*)')->as('count');
$buildDbQuery($countDbQuery, $user, $tab);
$postCount = intval($countDbQuery->get('row')['count']);
$pageCount = ceil($postCount / $postsPerPage); $pageCount = ceil($postCount / $postsPerPage);
$page = max(1, min($pageCount, $page)); PostModel::preloadTags($posts);
$searchDbQuery = R::$f->begin()->select('*');
$buildDbQuery($searchDbQuery, $user, $tab);
$searchDbQuery->orderBy('id DESC')
->limit('?')
->put($postsPerPage)
->offset('?')
->put(($page - 1) * $postsPerPage);
$posts = $searchDbQuery->get();
$posts = R::convertToBeans('post', $posts);
R::preload($posts, ['uploader' => 'user']);
$this->context->transport->user = $user;
$this->context->transport->tab = $tab; $this->context->transport->tab = $tab;
$this->context->transport->lastSearchQuery = $query;
$this->context->transport->paginator = new StdClass; $this->context->transport->paginator = new StdClass;
$this->context->transport->paginator->page = $page; $this->context->transport->paginator->page = $page;
$this->context->transport->paginator->pageCount = $pageCount; $this->context->transport->paginator->pageCount = $pageCount;
@ -405,8 +443,7 @@ class UserController
*/ */
public function toggleSafetyAction($safety) public function toggleSafetyAction($safety)
{ {
if (!$this->context->loggedIn) PrivilegesHelper::confirmWithException(Privilege::ChangeUserSettings, PrivilegesHelper::getIdentitySubPrivilege($this->context->user));
throw new SimpleException('Not logged in');
if (!in_array($safety, PostSafety::getAll())) if (!in_array($safety, PostSafety::getAll()))
throw new SimpleExcetpion('Invalid safety'); throw new SimpleExcetpion('Invalid safety');
@ -414,9 +451,11 @@ class UserController
$this->context->user->enableSafety($safety, $this->context->user->enableSafety($safety,
!$this->context->user->hasEnabledSafety($safety)); !$this->context->user->hasEnabledSafety($safety));
R::store($this->context->user); if ($this->context->user->accessRank != AccessRank::Anonymous)
UserModel::save($this->context->user);
AuthController::doReLog();
$this->context->transport->success = true; StatusHelper::success();
} }
@ -427,8 +466,6 @@ class UserController
public function registrationAction() public function registrationAction()
{ {
$this->context->handleExceptions = true; $this->context->handleExceptions = true;
$this->context->stylesheets []= 'auth.css';
$this->context->subTitle = 'registration form';
//check if already logged in //check if already logged in
if ($this->context->loggedIn) if ($this->context->loggedIn)
@ -446,57 +483,63 @@ class UserController
$this->context->suppliedPassword2 = $suppliedPassword2; $this->context->suppliedPassword2 = $suppliedPassword2;
$this->context->suppliedEmail = $suppliedEmail; $this->context->suppliedEmail = $suppliedEmail;
if ($suppliedName !== null) if (InputHelper::get('submit'))
{ {
$suppliedName = Model_User::validateUserName($suppliedName); $suppliedName = UserModel::validateUserName($suppliedName);
if ($suppliedPassword1 != $suppliedPassword2) if ($suppliedPassword1 != $suppliedPassword2)
throw new SimpleException('Specified passwords must be the same'); throw new SimpleException('Specified passwords must be the same');
$suppliedPassword = Model_User::validatePassword($suppliedPassword1); $suppliedPassword = UserModel::validatePassword($suppliedPassword1);
$suppliedEmail = Model_User::validateEmail($suppliedEmail); $suppliedEmail = UserModel::validateEmail($suppliedEmail);
if (empty($suppliedEmail) and $this->config->registration->needEmailForRegistering) if (empty($suppliedEmail) and $this->config->registration->needEmailForRegistering)
throw new SimpleException('E-mail address is required - you will be sent confirmation e-mail.'); throw new SimpleException('E-mail address is required - you will be sent confirmation e-mail.');
//register the user //register the user
$dbUser = R::dispense('user'); $dbUser = UserModel::spawn();
$dbUser->name = $suppliedName; $dbUser->name = $suppliedName;
$dbUser->pass_salt = md5(mt_rand() . uniqid()); $dbUser->passHash = UserModel::hashPassword($suppliedPassword, $dbUser->passSalt);
$dbUser->pass_hash = Model_User::hashPassword($suppliedPassword, $dbUser->pass_salt); $dbUser->emailUnconfirmed = $suppliedEmail;
$dbUser->email_unconfirmed = $suppliedEmail;
//prepare unique registration token $dbUser->joinDate = time();
do if (UserModel::getCount() == 0)
{ {
$emailToken = md5(mt_rand() . uniqid()); //very first user
} $dbUser->accessRank = AccessRank::Admin;
while (R::findOne('user', 'email_token = ?', [$emailToken]) !== null); $dbUser->staffConfirmed = true;
$dbUser->email_token = $emailToken; $dbUser->emailUnconfirmed = null;
$dbUser->emailConfirmed = $suppliedEmail;
$dbUser->join_date = time();
if (R::findOne('user') === null)
{
$dbUser->access_rank = AccessRank::Admin;
$dbUser->staff_confirmed = true;
$dbUser->email_confirmed = $suppliedEmail;
} }
else else
{ {
$dbUser->access_rank = AccessRank::Registered; $dbUser->accessRank = AccessRank::Registered;
$dbUser->staff_confirmed = false; $dbUser->staffConfirmed = false;
$dbUser->staff_confirmed = null; $dbUser->staffConfirmed = null;
if (!empty($dbUser->email_unconfirmed))
self::sendEmailConfirmation($dbUser);
} }
//save the user to db if everything went okay //save the user to db if everything went okay
R::store($dbUser); UserModel::save($dbUser);
$this->context->transport->success = true;
if (!empty($dbUser->emailUnconfirmed))
self::sendEmailChangeConfirmation($dbUser);
$message = 'Congratulations, your account was created.';
if (!empty($this->context->mailSent))
{
$message .= ' Please wait for activation e-mail.';
if ($this->config->registration->staffActivation)
$message .= ' After this, your registration must be confirmed by staff.';
}
elseif ($this->config->registration->staffActivation)
$message .= ' Your registration must be now confirmed by staff.';
LogHelper::log('{subject} just signed up', ['subject' => TextHelper::reprUser($dbUser)]);
StatusHelper::success($message);
if (!$this->config->registration->needEmailForRegistering and !$this->config->registration->staffActivation) if (!$this->config->registration->needEmailForRegistering and !$this->config->registration->staffActivation)
{ {
$_SESSION['user-id'] = $dbUser->id; $this->context->user = $dbUser;
\Chibi\Registry::getBootstrap()->attachUser(); AuthController::doReLog();
} }
} }
} }
@ -508,27 +551,109 @@ class UserController
*/ */
public function activationAction($token) public function activationAction($token)
{ {
$this->context->subTitle = 'account activation'; $this->context->viewName = 'message';
LayoutHelper::setSubTitle('account activation');
if (empty($token)) $dbToken = TokenModel::findByToken($token);
throw new SimpleException('Invalid activation token'); TokenModel::checkValidity($dbToken);
$dbUser = R::findOne('user', 'email_token = ?', [$token]); $dbUser = $dbToken->getUser();
if ($dbUser === null) $dbUser->emailConfirmed = $dbUser->emailUnconfirmed;
throw new SimpleException('No user with such activation token'); $dbUser->emailUnconfirmed = null;
$dbToken->used = true;
TokenModel::save($dbToken);
UserModel::save($dbUser);
if (!$dbUser->email_unconfirmed) LogHelper::log('{subject} just activated account', ['subject' => TextHelper::reprUser($dbUser)]);
throw new SimpleException('This user was already activated'); $message = 'Activation completed successfully.';
if ($this->config->registration->staffActivation)
$dbUser->email_confirmed = $dbUser->email_unconfirmed; $message .= ' However, your account still must be confirmed by staff.';
$dbUser->email_unconfirmed = null; StatusHelper::success($message);
R::store($dbUser);
$this->context->transport->success = true;
if (!$this->config->registration->staffActivation) if (!$this->config->registration->staffActivation)
{ {
$_SESSION['user-id'] = $dbUser->id; $this->context->user = $dbUser;
\Chibi\Registry::getBootstrap()->attachUser(); AuthController::doReLog();
}
}
/**
* @route /password-reset/{token}
*/
public function passwordResetAction($token)
{
$this->context->viewName = 'message';
LayoutHelper::setSubTitle('password reset');
$dbToken = TokenModel::findByToken($token);
TokenModel::checkValidity($dbToken);
$alphabet = array_merge(range('A', 'Z'), range('a', 'z'), range('0', '9'));
$randomPassword = join('', array_map(function($x) use ($alphabet)
{
return $alphabet[$x];
}, array_rand($alphabet, 8)));
$dbUser = $dbToken->getUser();
$dbUser->passHash = UserModel::hashPassword($randomPassword, $dbUser->passSalt);
$dbToken->used = true;
TokenModel::save($dbToken);
UserModel::save($dbUser);
LogHelper::log('{subject} just reset password', ['subject' => TextHelper::reprUser($dbUser)]);
$message = 'Password reset successful. Your new password is **' . $randomPassword . '**.';
StatusHelper::success($message);
$this->context->user = $dbUser;
AuthController::doReLog();
}
/**
* @route /password-reset-proxy
*/
public function passwordResetProxyAction()
{
$this->context->viewName = 'user-select';
LayoutHelper::setSubTitle('password reset');
if (InputHelper::get('submit'))
{
$name = InputHelper::get('name');
$user = UserModel::findByNameOrEmail($name);
if (empty($user->emailConfirmed))
throw new SimpleException('This user has no e-mail confirmed; password reset cannot proceed');
self::sendPasswordResetConfirmation($user);
StatusHelper::success('E-mail sent. Follow instructions to reset password.');
}
}
/**
* @route /activation-proxy
*/
public function activationProxyAction()
{
$this->context->viewName = 'user-select';
LayoutHelper::setSubTitle('account activation');
if (InputHelper::get('submit'))
{
$name = InputHelper::get('name');
$user = UserModel::findByNameOrEmail($name);
if (empty($user->emailUnconfirmed))
{
if (!empty($user->emailConfirmed))
throw new SimpleException('E-mail was already confirmed; activation skipped');
else
throw new SimpleException('This user has no e-mail specified; activation cannot proceed');
}
self::sendEmailChangeConfirmation($user);
StatusHelper::success('Activation e-mail resent.');
} }
} }
} }

View File

@ -1,42 +1,163 @@
<?php <?php
class CustomMarkdown extends \Michelf\Markdown class CustomMarkdown extends \Michelf\Markdown
{ {
public function __construct() protected $simple = false;
public function __construct($simple = false)
{ {
$this->simple = $simple;
$this->no_markup = true; $this->no_markup = true;
$this->span_gamut += ['doSpoilers' => 71]; $this->span_gamut += ['doSpoilers' => 71];
$this->span_gamut += ['doSearchPermalinks' => 72];
$this->span_gamut += ['doStrike' => 6];
$this->span_gamut += ['doUsers' => 7];
$this->span_gamut += ['doPosts' => 8]; $this->span_gamut += ['doPosts' => 8];
$this->span_gamut += ['doTags' => 9]; $this->span_gamut += ['doTags' => 9];
$this->span_gamut += ['doAutoLinks2' => 29];
//fix italics/bold in the middle of sentence
$prop = ['em_relist', 'strong_relist', 'em_strong_relist'];
for ($i = 0; $i < 3; $i ++)
{
$this->{$prop[$i]}[''] = '(?:(?<!\*)' . str_repeat('\*', $i + 1) . '(?!\*)|(?<![a-zA-Z0-9_])' . str_repeat('_', $i + 1) . '(?!_))(?=\S|$)(?![\.,:;]\s)';
$this->{$prop[$i]}[str_repeat('*', $i + 1)] = '(?<=\S|^)(?<!\*)' . str_repeat('\*', $i + 1) . '(?!\*)';
$this->{$prop[$i]}[str_repeat('_', $i + 1)] = '(?<=\S|^)(?<!_)' . str_repeat('_', $i + 1) . '(?![a-zA-Z0-9_])';
}
parent::__construct(); parent::__construct();
} }
//make atx-style headers require space after hash
protected function doHeaders($text)
{
$text = preg_replace_callback('{ ^(.+?)[ ]*\n(=+|-+)[ ]*\n+ }mx', [&$this, '_doHeaders_callback_setext'], $text);
$text = preg_replace_callback('{^(\#{1,6})[ ]+(.+?)[ ]*\#*\n+}xm', [&$this, '_doHeaders_callback_atx'], $text);
return $text;
}
//disable paragraph forming when using simple markdown
protected function formParagraphs($text)
{
if ($this->simple)
{
$text = preg_replace('/\A\n+|\n+\z/', '', $text);
$grafs = preg_split('/\n{2,}/', $text, -1, PREG_SPLIT_NO_EMPTY);
foreach ($grafs as $key => $value)
{
if (!preg_match('/^B\x1A[0-9]+B$/', $value))
{
$value = $this->runSpanGamut($value);
$grafs[$key] = $this->unhash($value);
}
else
{
$grafs[$key] = $this->html_hashes[$value];
}
}
return implode("\n\n", $grafs);
}
return parent::formParagraphs($text);
}
public static function simpleTransform($text)
{
$parser = new self(true);
return $parser->transform($text);
}
//automatically form links out of http://(...) and www.(...)
protected function doAutoLinks2($text)
{
$text = preg_replace_callback('{(?<!<)((https?|ftp):[^\'"><\s(){}]+)}i', [&$this, '_doAutoLinks_url_callback'], $text);
$text = preg_replace_callback('{(?<![^\s\(\)\[\]])(www\.[^\'"><\s(){}]+)}i', [&$this, '_doAutoLinks_url_callback'], $text);
return $text;
}
//extend anchors callback for doAutolinks2
protected function _doAnchors_inline_callback($matches)
{
if ($matches[3] == '')
$url = &$matches[4];
else
$url = &$matches[3];
if (!preg_match('/^((https?|ftp):|)\/\//', $url))
$url = 'http://' . $url;
return parent::_doAnchors_inline_callback($matches);
}
//handle white characters inside code blocks
//so that they won't be optimized away by prettifying HTML
protected function _doCodeBlocks_callback($matches)
{
$codeblock = $matches[1];
$codeblock = $this->outdent($codeblock);
$codeblock = htmlspecialchars($codeblock, ENT_NOQUOTES);
$codeblock = preg_replace('/\A\n+|\n+\z/', '', $codeblock);
$codeblock = preg_replace('/\n/', '<br/>', $codeblock);
$codeblock = preg_replace('/\t/', '&tab;', $codeblock);
$codeblock = preg_replace('/ /', '&nbsp;', $codeblock);
$codeblock = "<pre><code>$codeblock\n</code></pre>";
return "\n\n".$this->hashBlock($codeblock)."\n\n";
}
//change hard breaks trigger - simple \n followed by text
//instead of two spaces followed by \n
protected function doHardBreaks($text) protected function doHardBreaks($text)
{ {
return preg_replace_callback('/\n/', array(&$this, '_doHardBreaks_callback'), $text); return preg_replace_callback('/\n(?=[\[\]\(\)\w])/', [&$this, '_doHardBreaks_callback'], $text);
}
protected function doStrike($text)
{
return preg_replace_callback('{(~~|---)([^~]+)\1}', function($x)
{
return $this->hashPart('<del>' . $x[2] . '</del>');
}, $text);
} }
protected function doSpoilers($text) protected function doSpoilers($text)
{ {
if (is_array($text)) if (is_array($text))
{ $text = $this->hashBlock('<span class="spoiler">') . $this->runSpanGamut($text[1]) . $this->hashBlock('</span>');
$text = $this->hashPart('<span class="spoiler">') . $text[1] . $this->hashPart('</span>');
}
return preg_replace_callback('{\[spoiler\]((?:[^\[]|\[(?!\/?spoiler\])|(?R))+)\[\/spoiler\]}is', [__CLASS__, 'doSpoilers'], $text); return preg_replace_callback('{\[spoiler\]((?:[^\[]|\[(?!\/?spoiler\])|(?R))+)\[\/spoiler\]}is', [__CLASS__, 'doSpoilers'], $text);
} }
protected function doPosts($text) protected function doPosts($text)
{ {
return preg_replace_callback('/@(\d+)/', function($x) $link = \Chibi\UrlHelper::route('post', 'view', ['id' => '_post_']);
return preg_replace_callback('/(?:(?<![^\s\(\)\[\]]))@(\d+)/', function($x) use ($link)
{ {
return $this->hashPart('<a href="' . \Chibi\UrlHelper::route('post', 'view', ['id' => $x[1]]) . '">') . $x[0] . $this->hashPart('</a>'); return $this->hashPart('<a href="' . str_replace('_post_', $x[1], $link) . '">' . $x[0] . '</a>');
}, $text); }, $text);
} }
protected function doTags($text) protected function doTags($text)
{ {
return preg_replace_callback('/#([a-zA-Z0-9_-]+)/', function($x) $link = \Chibi\UrlHelper::route('post', 'list', ['query' => '_query_']);
return preg_replace_callback('/(?:(?<![^\s\(\)\[\]]))#([()\[\]a-zA-Z0-9_.-]+)/', function($x) use ($link)
{ {
return $this->hashPart('<a href="' . \Chibi\UrlHelper::route('post', 'list', ['query' => $x[1]]) . '">') . $x[0] . $this->hashPart('</a>'); return $this->hashPart('<a href="' . str_replace('_query_', $x[1], $link) . '">' . $x[0] . '</a>');
}, $text);
}
protected function doUsers($text)
{
$link = \Chibi\UrlHelper::route('user', 'view', ['name' => '_name_']);
return preg_replace_callback('/(?:(?<![^\s\(\)\[\]]))\+([a-zA-Z0-9_-]+)/', function($x) use ($link)
{
return $this->hashPart('<a href="' . str_replace('_name_', $x[1], $link) . '">' . $x[0] . '</a>');
}, $text);
}
protected function doSearchPermalinks($text)
{
$link = \Chibi\UrlHelper::route('post', 'list', ['query' => '_query_']);
return preg_replace_callback('{\[search\]((?:[^\[]|\[(?!\/?search\]))+)\[\/search\]}is', function($x) use ($link)
{
return $this->hashPart('<a href="' . str_replace('_query_', urlencode($x[1]), $link) . '">' . $x[1] . '</a>');
}, $text); }, $text);
} }
} }

View File

@ -0,0 +1,20 @@
<?php
class BenchmarkHelper
{
protected static $lastTime;
public static function init()
{
self::$lastTime = microtime(true);
}
public static function tick()
{
$t = microtime(true);
$lt = self::$lastTime;
self::$lastTime = $t;
return $t - $lt;
}
}
BenchmarkHelper::init();

View File

@ -0,0 +1,40 @@
<?php
class CustomAssetViewDecorator extends \Chibi\AssetViewDecorator
{
private static $pageThumb = null;
private static $subTitle = null;
public static function setSubTitle($text)
{
self::$subTitle = $text;
}
public static function setPageThumb($path)
{
self::$pageThumb = $path;
}
public function transformHtml($html)
{
self::$title = isset(self::$subTitle)
? sprintf('%s&nbsp;&ndash;&nbsp;%s', self::$title, self::$subTitle)
: self::$title;
$html = parent::transformHtml($html);
$headSnippet = '<meta property="og:title" content="' . self::$title . '"/>';
$headSnippet .= '<meta property="og:url" content="' . \Chibi\UrlHelper::currentUrl() . '"/>';
if (!empty(self::$pageThumb))
$headSnippet .= '<meta property="og:image" content="' . self::$pageThumb . '"/>';
$bodySnippet = '<script type="text/javascript">';
$bodySnippet .= '$(function() {';
$bodySnippet .= '$(\'body\').trigger(\'dom-update\');';
$bodySnippet .= '});';
$bodySnippet .= '</script>';
$html = str_replace('</head>', $headSnippet . '</head>', $html);
$html = str_replace('</body>', $bodySnippet . '</body>', $html);
return $html;
}
}

99
src/Helpers/LogHelper.php Normal file
View File

@ -0,0 +1,99 @@
<?php
class LogHelper
{
static $context;
static $config;
static $autoFlush;
static $buffer;
public static function init()
{
self::$autoFlush = true;
self::$buffer = [];
}
public static function bufferChanges()
{
self::$autoFlush = false;
}
public static function flush()
{
$fh = fopen(self::getLogPath(), 'ab');
if (!$fh)
throw new SimpleException('Cannot write to log files');
if (flock($fh, LOCK_EX))
{
foreach (self::$buffer as $logEvent)
fwrite($fh, $logEvent->getFullText() . PHP_EOL);
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
}
self::$buffer = [];
self::$autoFlush = true;
}
public static function getLogPath()
{
return TextHelper::absolutePath(
\Chibi\Registry::getConfig()->main->logsPath . DS . date('Y-m') . '.log');
}
public static function log($text, array $tokens = [])
{
self::$buffer []= new LogEvent($text, $tokens);
if (self::$autoFlush)
self::flush();
}
//methods for manipulating buffered logs
public static function getBuffer()
{
return self::$buffer;
}
public static function setBuffer(array $buffer)
{
self::$buffer = $buffer;
}
}
class LogEvent
{
public $timestamp;
public $text;
public $ip;
public $tokens;
public function __construct($text, array $tokens = [])
{
$this->timestamp = time();
$this->text = $text;
$this->ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
$context = \Chibi\Registry::getContext();
$tokens['anon'] = UserModel::getAnonymousName();
if ($context->loggedIn and isset($context->user))
$tokens['user'] = TextHelper::reprUser($context->user->name);
else
$tokens['user'] = $tokens['anon'];
$this->tokens = $tokens;
}
public function getText()
{
return TextHelper::replaceTokens($this->text, $this->tokens);
}
public function getFullText()
{
$date = date('Y-m-d H:i:s', $this->timestamp);
$ip = $this->ip;
$text = $this->getText();
$line = sprintf('[%s] %s: %s', $date, $ip, $text);
return $line;
}
}
LogHelper::init();

View File

@ -17,11 +17,20 @@ class PrivilegesHelper
$minAccessRank = TextHelper::resolveConstant($minAccessRankName, 'AccessRank'); $minAccessRank = TextHelper::resolveConstant($minAccessRankName, 'AccessRank');
self::$privileges[$key] = $minAccessRank; self::$privileges[$key] = $minAccessRank;
if (!isset(self::$privileges[$privilegeName]) or
self::$privileges[$privilegeName] > $minAccessRank)
{
self::$privileges[$privilegeName] = $minAccessRank;
}
} }
} }
public static function confirm($privilege, $subPrivilege = null) public static function confirm($privilege, $subPrivilege = null)
{ {
if (php_sapi_name() == 'cli')
return true;
$user = \Chibi\Registry::getContext()->user; $user = \Chibi\Registry::getContext()->user;
$minAccessRank = AccessRank::Admin; $minAccessRank = AccessRank::Admin;
@ -39,30 +48,41 @@ class PrivilegesHelper
} }
} }
return intval($user->access_rank) >= $minAccessRank; return intval($user->accessRank) >= $minAccessRank;
} }
public static function confirmWithException($privilege, $subPrivilege = null) public static function confirmWithException($privilege, $subPrivilege = null)
{ {
if (!self::confirm($privilege, $subPrivilege)) if (!self::confirm($privilege, $subPrivilege))
{
throw new SimpleException('Insufficient privileges'); throw new SimpleException('Insufficient privileges');
}
} }
public static function getIdentitySubPrivilege($user) public static function getIdentitySubPrivilege($user)
{ {
if (!$user) if (!$user)
return false; return 'all';
$userFromContext = \Chibi\Registry::getContext()->user; $userFromContext = \Chibi\Registry::getContext()->user;
return $user->id == $userFromContext->id ? 'own' : 'all'; return $user->id == $userFromContext->id ? 'own' : 'all';
} }
public static function confirmEmail($user) public static function confirmEmail($user)
{ {
if (!$user->email_confirmed) if (!$user->emailConfirmed)
throw new SimpleException('Need e-mail address confirmation to continue'); throw new SimpleException('Need e-mail address confirmation to continue');
} }
public static function getAllowedSafety()
{
if (php_sapi_name() == 'cli')
return PostSafety::getAll();
$context = \Chibi\Registry::getContext();
return array_filter(PostSafety::getAll(), function($safety) use ($context)
{
return PrivilegesHelper::confirm(Privilege::ListPosts, PostSafety::toString($safety)) and
$context->user->hasEnabledSafety($safety);
});
}
} }
PrivilegesHelper::init(); PrivilegesHelper::init();

View File

@ -0,0 +1,15 @@
<?php
class SessionHelper
{
public static function get($key, $default = null)
{
if (!isset($_SESSION[$key]))
return $default;
return $_SESSION[$key];
}
public static function set($key, $value)
{
$_SESSION[$key] = $value;
}
}

View File

@ -0,0 +1,33 @@
<?php
class StatusHelper
{
private static function flag($success, $message = null)
{
$context = \Chibi\Registry::getContext();
if (!empty($message))
{
if (!preg_match('/[.?!]$/', $message))
$message .= '.';
$context->transport->message = $message;
$context->transport->messageHtml = TextHelper::parseMarkdown($message, true);
}
$context->transport->success = $success;
}
public static function init()
{
$context = \Chibi\Registry::getContext();
$context->transport->success = null;
}
public static function success($message = null)
{
self::flag(true, $message);
}
public static function failure($message = null)
{
self::flag(false, $message);
}
}

View File

@ -17,9 +17,21 @@ class TextHelper
return $text; return $text;
} }
//todo: convert to enum and make one method
public static function snakeCaseToCamelCase($string, $lower = false)
{
$string = explode('_', $string);
$string = array_map('trim', $string);
$string = array_map('ucfirst', $string);
$string = join('', $string);
if ($lower)
$string = lcfirst($string);
return $string;
}
public static function kebabCaseToCamelCase($string) public static function kebabCaseToCamelCase($string)
{ {
$string = preg_split('/-/', $string); $string = explode('-', $string);
$string = array_map('trim', $string); $string = array_map('trim', $string);
$string = array_map('ucfirst', $string); $string = array_map('ucfirst', $string);
$string = join('', $string); $string = join('', $string);
@ -48,6 +60,14 @@ class TextHelper
return $string; return $string;
} }
public static function humanCaseToKebabCase($string)
{
$string = trim($string);
$string = str_replace(' ', '-', $string);
$string = strtolower($string);
return $string;
}
public static function resolveConstant($constantName, $className = null) public static function resolveConstant($constantName, $className = null)
{ {
$constantName = self::kebabCaseToCamelCase($constantName); $constantName = self::kebabCaseToCamelCase($constantName);
@ -63,25 +83,48 @@ class TextHelper
return constant($constantName); return constant($constantName);
} }
private static function useUnits($number, $base, $suffixes) private static function stripUnits($string, $base, $suffixes)
{
$suffix = substr($string, -1, 1);
$index = array_search($suffix, $suffixes);
return floatval($string) * pow($base, $index !== false ? $index : 0);
}
private static function useUnits($number, $base, $suffixes, $fmtCallback = null)
{ {
$suffix = array_shift($suffixes); $suffix = array_shift($suffixes);
if ($number < $base)
{ while ($number >= $base and !empty($suffixes))
return sprintf('%d%s', $number, $suffix);
}
do
{ {
$suffix = array_shift($suffixes); $suffix = array_shift($suffixes);
$number /= (float) $base; $number /= (float) $base;
} }
while ($number >= $base and !empty($suffixes));
return sprintf('%.01f%s', $number, $suffix); if ($fmtCallback === null)
{
$fmtCallback = function($number, $suffix)
{
if ($suffix == '')
return $number;
return sprintf('%.01f%s', $number, $suffix);
};
}
return $fmtCallback($number, $suffix);
} }
public static function useBytesUnits($number) public static function useBytesUnits($number)
{ {
return self::useUnits($number, 1024, ['B', 'K', 'M', 'G']); return self::useUnits(
$number,
1024,
['B', 'K', 'M', 'G'],
function($number, $suffix)
{
if ($number < 20 and $suffix != 'B')
return sprintf('%.01f%s', $number, $suffix);
return sprintf('%.0f%s', $number, $suffix);
});
} }
public static function useDecimalUnits($number) public static function useDecimalUnits($number)
@ -89,6 +132,16 @@ class TextHelper
return self::useUnits($number, 1000, ['', 'K', 'M']); return self::useUnits($number, 1000, ['', 'K', 'M']);
} }
public static function stripBytesUnits($string)
{
return self::stripUnits($string, 1024, ['B', 'K', 'M', 'G']);
}
public static function stripDecimalUnits($string)
{
return self::stripUnits($string, 1000, ['', 'K', 'M']);
}
public static function removeUnsafeKeys(&$input, $regex) public static function removeUnsafeKeys(&$input, $regex)
{ {
if (is_array($input)) if (is_array($input))
@ -116,37 +169,186 @@ class TextHelper
public static function jsonEncode($obj, $illegalKeysRegex = '') public static function jsonEncode($obj, $illegalKeysRegex = '')
{ {
if (is_array($obj)) if (is_array($obj))
$set = function($key, $val) use ($obj) { $obj[$key] = $val; };
else
$set = function($key, $val) use ($obj) { $obj->$key = $val; };
foreach ($obj as $key => $val)
{ {
foreach ($obj as $key => $val) if ($val instanceof Exception)
{ {
if ($val instanceof RedBean_OODBBean) $set($key, ['message' => $val->getMessage(), 'trace' => explode("\n", $val->getTraceAsString())]);
{
$obj[$key] = R::exportAll($val);
}
}
}
elseif (is_object($obj))
{
foreach ($obj as $key => $val)
{
if ($val instanceof RedBean_OODBBean)
{
$obj->$key = R::exportAll($val);
}
} }
} }
if (!empty($illegalKeysRegex)) if (!empty($illegalKeysRegex))
self::removeUnsafeKeys($obj, $illegalKeysRegex); self::removeUnsafeKeys($obj, $illegalKeysRegex);
return json_encode($obj); return json_encode($obj, JSON_UNESCAPED_UNICODE);
} }
public static function parseMarkdown($text, $inline = false) public static function parseMarkdown($text, $simple = false)
{ {
$output = CustomMarkdown::defaultTransform($text); if ($simple)
if ($inline) return CustomMarkdown::simpleTransform($text);
$output = preg_replace('{</?p>}', '', $output); else
return $output; return CustomMarkdown::defaultTransform($text);
}
public static function reprPost($post)
{
if (!is_object($post))
return '@' . $post;
return '@' . $post->id;
}
public static function reprUser($user)
{
if (!is_object($user))
return '+' . $user;
return '+' . $user->name;
}
public static function reprTag($tag)
{
if (!is_object($tag))
return '#' . $tag;
return '#' . $tag->name;
}
public static function reprTags($tags)
{
$x = [];
foreach ($tags as $tag)
$x []= self::reprTag($tag);
natcasesort($x);
return join(', ', $x);
}
public static function encrypt($text)
{
$salt = \Chibi\Registry::getConfig()->main->salt;
$alg = MCRYPT_RIJNDAEL_256;
$mode = MCRYPT_MODE_ECB;
$iv = mcrypt_create_iv(mcrypt_get_iv_size($alg, $mode), MCRYPT_RAND);
return trim(base64_encode(mcrypt_encrypt($alg, $salt, $text, $mode, $iv)));
}
public static function decrypt($text)
{
$salt = \Chibi\Registry::getConfig()->main->salt;
$alg = MCRYPT_RIJNDAEL_256;
$mode = MCRYPT_MODE_ECB;
$iv = mcrypt_create_iv(mcrypt_get_iv_size($alg, $mode), MCRYPT_RAND);
return trim(mcrypt_decrypt($alg, $salt, base64_decode($text), $mode, $iv));
}
public static function cleanPath($path)
{
$path = str_replace(['/', '\\'], DS, $path);
$path = preg_replace('{[^' . DS . ']+' . DS . '\.\.(' . DS . '|$)}', '', $path);
$path = preg_replace('{(' . DS . '|^)\.' . DS . '}', '\1', $path);
$path = preg_replace('{' . DS . '{2,}}', DS, $path);
$path = rtrim($path, DS);
return $path;
}
public static function absolutePath($path)
{
if ($path{0} != DS)
$path = \Chibi\Registry::getContext()->rootDir . DS . $path;
$path = self::cleanPath($path);
return $path;
}
public static function secureWhitespace($text)
{
$text = str_replace(["\r\n", "\r", "\n"], '&#10;', $text);
$text = str_replace(' ', '&#32;', $text);
return $text;
}
const HTML_OPEN = 1;
const HTML_CLOSE = 2;
const HTML_LEAF = 3;
public static function htmlTag($tagName, $tagStyle, array $attributes = [])
{
$html = '<';
if ($tagStyle == self::HTML_CLOSE)
$html .= '/';
$html .= $tagName;
if ($tagStyle == self::HTML_OPEN or $tagStyle == self::HTML_LEAF)
{
foreach ($attributes as $key => $value)
{
$html .= ' ' . $key . '="' . $value . '"';
}
}
if ($tagStyle == self::HTML_LEAF)
$html .= '/';
$html .= '>';
return $html;
}
public static function formatDate($date, $plain = true)
{
if (!$date)
return 'Unknown';
if ($plain)
return date('Y-m-d H:i:s', $date);
$now = time();
$diff = abs($now - $date);
$future = $now < $date;
$mul = 60;
if ($diff < $mul)
return $future ? 'in a few seconds' : 'just now';
if ($diff < $mul * 2)
return $future ? 'in a minute' : 'a minute ago';
$prevMul = $mul; $mul *= 60;
if ($diff < $mul)
return $future ? 'in ' . round($diff / $prevMul) . ' minutes' : round($diff / $prevMul) . ' minutes ago';
if ($diff < $mul * 2)
return $future ? 'in an hour' : 'an hour ago';
$prevMul = $mul; $mul *= 24;
if ($diff < $mul)
return $future ? 'in ' . round($diff / $prevMul) . ' hours' : round($diff / $prevMul) . ' hours ago';
if ($diff < $mul * 2)
return $future ? 'tomorrow' : 'yesterday';
$prevMul = $mul; $mul *= 30.42;
if ($diff < $mul)
return $future ? 'in ' . round($diff / $prevMul) . ' days' : round($diff / $prevMul) . ' days ago';
if ($diff < $mul * 2)
return $future ? 'in a month' : 'a month ago';
$prevMul = $mul; $mul *= 12;
if ($diff < $mul)
return $future ? 'in ' . round($diff / $prevMul) . ' months' : round($diff / $prevMul) . ' months ago';
if ($diff < $mul * 2)
return $future ? 'in a year' : 'a year ago';
return $future ? 'in ' . round($diff / $mul) . ' years' : round($diff / $prevMul) . ' years ago';
}
public static function resolveMimeType($mimeType)
{
$mimeTypes = [
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/png' => 'png',
'application/x-shockwave-flash' => 'swf'];
return isset($mimeTypes[$mimeType])
? $mimeTypes[$mimeType]
: null;
} }
} }

View File

@ -0,0 +1,149 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
abstract class AbstractCrudModel implements IModel
{
public static function spawn()
{
$entityClassName = static::getEntityClassName();
return new $entityClassName();
}
public static function remove($entities)
{
throw new NotImplementedException();
}
public static function save($entity)
{
throw new NotImplementedException();
}
public static function findById($key, $throw = true)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('*');
$stmt->setTable(static::getTableName());
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($key)));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('Invalid ' . static::getTableName() . ' ID "' . $key . '"');
return null;
}
public static function findByIds(array $ids)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('*');
$stmt->setTable(static::getTableName());
$stmt->setCriterion(Sql\InFunctor::fromArray('id', Sql\Binding::fromArray(array_unique($ids))));
$rows = Database::fetchAll($stmt);
if ($rows)
return self::convertRows($rows);
return [];
}
public static function getCount()
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setTable(static::getTableName());
return Database::fetchOne($stmt)['count'];
}
public static function getEntityClassName()
{
$modelClassName = get_called_class();
$entityClassName = str_replace('Model', 'Entity', $modelClassName);
return $entityClassName;
}
public static function convertRow($row)
{
$entity = self::spawn();
foreach ($row as $key => $val)
{
$key = TextHelper::snakeCaseToCamelCase($key, true);
$entity->$key = $val;
}
return $entity;
}
public static function convertRows(array $rows)
{
$keyCache = [];
$entities = [];
foreach ($rows as $i => $row)
{
$entity = self::spawn();
foreach ($row as $key => $val)
{
if (isset($keyCache[$key]))
$key = $keyCache[$key];
else
$key = $keyCache[$key] = TextHelper::snakeCaseToCamelCase($key, true);
$entity->$key = $val;
}
$entities[$i] = $entity;
}
return $entities;
}
public static function forgeId($entity)
{
$table = static::getTableName();
if (!Database::inTransaction())
throw new Exception('Can be run only within transaction');
if (!$entity->id)
{
$stmt = new Sql\InsertStatement();
$stmt->setTable($table);
Database::exec($stmt);
$entity->id = Database::lastInsertId();
}
}
public static function preloadOneToMany($entities,
$foreignEntityLocalSelector,
$foreignEntityForeignSelector,
$foreignEntityProcessor,
$foreignEntitySetter)
{
if (empty($entities))
return;
$foreignIds = [];
$entityMap = [];
foreach ($entities as $entity)
{
$foreignId = $foreignEntityLocalSelector($entity);
if (!isset($entityMap[$foreignId]))
$entityMap[$foreignId] = [];
$entityMap[$foreignId] []= $entity;
$foreignIds []= $foreignId;
}
$foreignEntities = $foreignEntityProcessor($foreignIds);
foreach ($foreignEntities as $foreignEntity)
{
$key = $foreignEntityForeignSelector($foreignEntity);
foreach ($entityMap[$key] as $entity)
$foreignEntitySetter($entity, $foreignEntity);
}
}
}

103
src/Models/CommentModel.php Normal file
View File

@ -0,0 +1,103 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class CommentModel extends AbstractCrudModel
{
public static function getTableName()
{
return 'comment';
}
public static function spawn()
{
$comment = new CommentEntity;
$comment->commentDate = time();
return $comment;
}
public static function save($comment)
{
Database::transaction(function() use ($comment)
{
self::forgeId($comment);
$bindings = [
'text' => $comment->text,
'post_id' => $comment->postId,
'comment_date' => $comment->commentDate,
'commenter_id' => $comment->commenterId];
$stmt = new Sql\UpdateStatement();
$stmt->setTable('comment');
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($comment->id)));
foreach ($bindings as $key => $val)
$stmt->setColumn($key, new Sql\Binding($val));
Database::exec($stmt);
});
}
public static function remove($comment)
{
Database::transaction(function() use ($comment)
{
$stmt = new Sql\DeleteStatement();
$stmt->setTable('comment');
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($comment->id)));
Database::exec($stmt);
});
}
public static function findAllByPostId($key)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('comment.*');
$stmt->setTable('comment');
$stmt->setCriterion(new Sql\EqualsFunctor('post_id', new Sql\Binding($key)));
$rows = Database::fetchAll($stmt);
if ($rows)
return self::convertRows($rows);
return [];
}
public static function preloadCommenters($comments)
{
self::preloadOneToMany($comments,
function($comment) { return $comment->commenterId; },
function($user) { return $user->id; },
function($userIds) { return UserModel::findByIds($userIds); },
function($comment, $user) { return $comment->setCache('commenter', $user); });
}
public static function preloadPosts($comments)
{
self::preloadOneToMany($comments,
function($comment) { return $comment->postId; },
function($post) { return $post->id; },
function($postIds) { return PostModel::findByIds($postIds); },
function($comment, $post) { $comment->setCache('post', $post); });
}
public static function validateText($text)
{
$text = trim($text);
$config = \Chibi\Registry::getConfig();
if (strlen($text) < $config->comments->minLength)
throw new SimpleException(sprintf('Comment must have at least %d characters', $config->comments->minLength));
if (strlen($text) > $config->comments->maxLength)
throw new SimpleException(sprintf('Comment must have at most %d characters', $config->comments->maxLength));
return $text;
}
}

View File

@ -0,0 +1,23 @@
<?php
class AbstractEntity
{
public $id;
protected $__cache;
public function setCache($key, $value)
{
$this->__cache[$key] = $value;
}
public function getCache($key)
{
return isset($this->__cache[$key])
? $this->__cache[$key]
: null;
}
public function hasCache($key)
{
return isset($this->__cache[$key]);
}
}

View File

@ -0,0 +1,43 @@
<?php
class CommentEntity extends AbstractEntity
{
public $text;
public $postId;
public $commentDate;
public $commenterId;
public function getText()
{
return TextHelper::parseMarkdown($this->text);
}
public function setPost($post)
{
$this->setCache('post', $post);
$this->postId = $post->id;
}
public function setCommenter($user)
{
$this->setCache('commenter', $user);
$this->commenterId = $user ? $user->id : null;
}
public function getPost()
{
if ($this->hasCache('post'))
return $this->getCache('post');
$post = PostModel::findById($this->postId);
$this->setCache('post', $post);
return $post;
}
public function getCommenter()
{
if ($this->hasCache('commenter'))
return $this->getCache('commenter');
$user = UserModel::findById($this->commenterId, false);
$this->setCache('commenter', $user);
return $user;
}
}

View File

@ -0,0 +1,448 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class PostEntity extends AbstractEntity
{
public $type;
public $name;
public $origName;
public $fileHash;
public $fileSize;
public $mimeType;
public $safety;
public $hidden;
public $uploadDate;
public $imageWidth;
public $imageHeight;
public $uploaderId;
public $source;
public $commentCount;
public $favCount;
public $score;
public function getUploader()
{
if ($this->hasCache('uploader'))
return $this->getCache('uploader');
$uploader = UserModel::findById($this->uploaderId, false);
$this->setCache('uploader', $uploader);
return $uploader;
}
public function setUploader($user)
{
$this->uploaderId = $user->id;
$this->setCache('uploader', $user);
}
public function getComments()
{
if ($this->hasCache('comments'))
return $this->getCache('comments');
$comments = CommentModel::findAllByPostId($this->id);
$this->setCache('comments', $comments);
return $comments;
}
public function getFavorites()
{
if ($this->hasCache('favoritee'))
return $this->getCache('favoritee');
$stmt = new Sql\SelectStatement();
$stmt->setColumn('user.*');
$stmt->setTable('user');
$stmt->addInnerJoin('favoritee', new Sql\EqualsFunctor('favoritee.user_id', 'user.id'));
$stmt->setCriterion(new Sql\EqualsFunctor('favoritee.post_id', new Sql\Binding($this->id)));
$rows = Database::fetchAll($stmt);
$favorites = UserModel::convertRows($rows);
$this->setCache('favoritee', $favorites);
return $favorites;
}
public function getRelations()
{
if ($this->hasCache('relations'))
return $this->getCache('relations');
$stmt = new Sql\SelectStatement();
$stmt->setColumn('post.*');
$stmt->setTable('post');
$binding = new Sql\Binding($this->id);
$stmt->addInnerJoin('crossref', (new Sql\DisjunctionFunctor)
->add(
(new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post.id', 'crossref.post2_id'))
->add(new Sql\EqualsFunctor('crossref.post_id', $binding)))
->add(
(new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post.id', 'crossref.post_id'))
->add(new Sql\EqualsFunctor('crossref.post2_id', $binding))));
$rows = Database::fetchAll($stmt);
$posts = PostModel::convertRows($rows);
$this->setCache('relations', $posts);
return $posts;
}
public function setRelations(array $relations)
{
foreach ($relations as $relatedPost)
if (!$relatedPost->id)
throw new Exception('All related posts must be saved');
$uniqueRelations = [];
foreach ($relations as $relatedPost)
$uniqueRelations[$relatedPost->id] = $relatedPost;
$relations = array_values($uniqueRelations);
$this->setCache('relations', $relations);
}
public function setRelationsFromText($relationsText)
{
$config = \Chibi\Registry::getConfig();
$relatedIds = array_filter(preg_split('/\D/', $relationsText));
$relatedPosts = [];
foreach ($relatedIds as $relatedId)
{
if ($relatedId == $this->id)
continue;
if (count($relatedPosts) > $config->browsing->maxRelatedPosts)
throw new SimpleException('Too many related posts (maximum: ' . $config->browsing->maxRelatedPosts . ')');
$relatedPosts []= PostModel::findById($relatedId);
}
$this->setRelations($relatedPosts);
}
public function getTags()
{
if ($this->hasCache('tags'))
return $this->getCache('tags');
$tags = TagModel::findAllByPostId($this->id);
$this->setCache('tags', $tags);
return $tags;
}
public function setTags(array $tags)
{
foreach ($tags as $tag)
if (!$tag->id)
throw new Exception('All tags must be saved');
$uniqueTags = [];
foreach ($tags as $tag)
$uniqueTags[$tag->id] = $tag;
$tags = array_values($uniqueTags);
$this->setCache('tags', $tags);
}
public function setTagsFromText($tagsText)
{
$tagNames = TagModel::validateTags($tagsText);
$tags = [];
foreach ($tagNames as $tagName)
{
$tag = TagModel::findByName($tagName, false);
if (!$tag)
{
$tag = TagModel::spawn();
$tag->name = $tagName;
TagModel::save($tag);
}
$tags []= $tag;
}
$this->setTags($tags);
}
public function isTaggedWith($tagName)
{
$tagName = trim(strtolower($tagName));
foreach ($this->getTags() as $tag)
if (trim(strtolower($tag->name)) == $tagName)
return true;
return false;
}
public function setHidden($hidden)
{
$this->hidden = boolval($hidden);
}
public function setSafety($safety)
{
$this->safety = PostModel::validateSafety($safety);
}
public function setSource($source)
{
$this->source = PostModel::validateSource($source);
}
public function getThumbCustomPath($width = null, $height = null)
{
return PostModel::getThumbCustomPath($this->name, $width, $height);
}
public function getThumbDefaultPath($width = null, $height = null)
{
return PostModel::getThumbDefaultPath($this->name, $width, $height);
}
public function getFullPath()
{
return PostModel::getFullPath($this->name);
}
public function hasCustomThumb($width = null, $height = null)
{
$thumbPath = $this->getThumbCustomPath($width, $height);
return file_exists($thumbPath);
}
public function setCustomThumbnailFromPath($srcPath)
{
$config = \Chibi\Registry::getConfig();
$mimeType = mime_content_type($srcPath);
if (!in_array($mimeType, ['image/gif', 'image/png', 'image/jpeg']))
throw new SimpleException('Invalid thumbnail type "' . $mimeType . '"');
list ($imageWidth, $imageHeight) = getimagesize($srcPath);
if ($imageWidth != $config->browsing->thumbWidth)
throw new SimpleException('Invalid thumbnail width (should be ' . $config->browsing->thumbWidth . ')');
if ($imageHeight != $config->browsing->thumbHeight)
throw new SimpleException('Invalid thumbnail height (should be ' . $config->browsing->thumbHeight . ')');
$dstPath = $this->getThumbCustomPath();
if (is_uploaded_file($srcPath))
move_uploaded_file($srcPath, $dstPath);
else
rename($srcPath, $dstPath);
}
public function makeThumb($width = null, $height = null)
{
list ($width, $height) = PostModel::validateThumbSize($width, $height);
$dstPath = $this->getThumbDefaultPath($width, $height);
$srcPath = $this->getFullPath();
if ($this->type == PostType::Youtube)
{
$tmpPath = tempnam(sys_get_temp_dir(), 'thumb') . '.jpg';
$contents = file_get_contents('http://img.youtube.com/vi/' . $this->fileHash . '/mqdefault.jpg');
file_put_contents($tmpPath, $contents);
if (file_exists($tmpPath))
$srcImage = imagecreatefromjpeg($tmpPath);
}
else switch ($this->mimeType)
{
case 'image/jpeg':
$srcImage = imagecreatefromjpeg($srcPath);
break;
case 'image/png':
$srcImage = imagecreatefrompng($srcPath);
break;
case 'image/gif':
$srcImage = imagecreatefromgif($srcPath);
break;
case 'application/x-shockwave-flash':
$srcImage = null;
exec('which dump-gnash', $tmp, $exitCode);
if ($exitCode == 0)
{
$tmpPath = tempnam(sys_get_temp_dir(), 'thumb') . '.png';
exec('dump-gnash --screenshot last --screenshot-file ' . $tmpPath . ' -1 -r1 --max-advances 15 ' . $srcPath);
if (file_exists($tmpPath))
$srcImage = imagecreatefrompng($tmpPath);
}
if (!$srcImage)
{
exec('which swfrender', $tmp, $exitCode);
if ($exitCode == 0)
{
$tmpPath = tempnam(sys_get_temp_dir(), 'thumb') . '.png';
exec('swfrender ' . $srcPath . ' -o ' . $tmpPath);
if (file_exists($tmpPath))
$srcImage = imagecreatefrompng($tmpPath);
}
}
break;
default:
break;
}
if (isset($tmpPath))
unlink($tmpPath);
if (!isset($srcImage))
return false;
$config = \Chibi\Registry::getConfig();
switch ($config->browsing->thumbStyle)
{
case 'outside':
$dstImage = ThumbnailHelper::cropOutside($srcImage, $width, $height);
break;
case 'inside':
$dstImage = ThumbnailHelper::cropInside($srcImage, $width, $height);
break;
default:
throw new SimpleException('Unknown thumbnail crop style');
}
imagejpeg($dstImage, $dstPath);
imagedestroy($srcImage);
imagedestroy($dstImage);
return true;
}
public function setContentFromPath($srcPath)
{
$this->fileSize = filesize($srcPath);
$this->fileHash = md5_file($srcPath);
if ($this->fileSize == 0)
throw new SimpleException('Specified file is empty');
$this->mimeType = mime_content_type($srcPath);
switch ($this->mimeType)
{
case 'image/gif':
case 'image/png':
case 'image/jpeg':
list ($imageWidth, $imageHeight) = getimagesize($srcPath);
$this->type = PostType::Image;
$this->imageWidth = $imageWidth;
$this->imageHeight = $imageHeight;
break;
case 'application/x-shockwave-flash':
list ($imageWidth, $imageHeight) = getimagesize($srcPath);
$this->type = PostType::Flash;
$this->imageWidth = $imageWidth;
$this->imageHeight = $imageHeight;
break;
default:
throw new SimpleException('Invalid file type "' . $this->mimeType . '"');
}
$duplicatedPost = PostModel::findByHash($this->fileHash, false);
if ($duplicatedPost !== null and (!$this->id or $this->id != $duplicatedPost->id))
throw new SimpleException('Duplicate upload: @' . $duplicatedPost->id);
$dstPath = $this->getFullPath();
if (is_uploaded_file($srcPath))
move_uploaded_file($srcPath, $dstPath);
else
rename($srcPath, $dstPath);
$thumbPath = $this->getThumbDefaultPath();
if (file_exists($thumbPath))
unlink($thumbPath);
}
public function setContentFromUrl($srcUrl)
{
if (!preg_match('/^https?:\/\//', $srcUrl))
throw new SimpleException('Invalid URL "' . $srcUrl . '"');
if (preg_match('/youtube.com\/watch.*?=([a-zA-Z0-9_-]+)/', $srcUrl, $matches))
{
$youtubeId = $matches[1];
$this->type = PostType::Youtube;
$this->mimeType = null;
$this->fileSize = null;
$this->fileHash = $youtubeId;
$this->imageWidth = null;
$this->imageHeight = null;
$thumbPath = $this->getThumbDefaultPath();
if (file_exists($thumbPath))
unlink($thumbPath);
$duplicatedPost = PostModel::findByHash($youtubeId, false);
if ($duplicatedPost !== null and (!$this->id or $this->id != $duplicatedPost->id))
throw new SimpleException('Duplicate upload: @' . $duplicatedPost->id);
return;
}
$srcPath = tempnam(sys_get_temp_dir(), 'upload') . '.dat';
//warning: low level sh*t ahead
//download the URL $srcUrl into $srcPath
$maxBytes = TextHelper::stripBytesUnits(ini_get('upload_max_filesize'));
set_time_limit(0);
$urlFP = fopen($srcUrl, 'rb');
if (!$urlFP)
throw new SimpleException('Cannot open URL for reading');
$srcFP = fopen($srcPath, 'w+b');
if (!$srcFP)
{
fclose($urlFP);
throw new SimpleException('Cannot open file for writing');
}
try
{
while (!feof($urlFP))
{
$buffer = fread($urlFP, 4 * 1024);
if (fwrite($srcFP, $buffer) === false)
throw new SimpleException('Cannot write into file');
fflush($srcFP);
if (ftell($srcFP) > $maxBytes)
throw new SimpleException('File is too big (maximum allowed size: ' . TextHelper::useBytesUnits($maxBytes) . ')');
}
}
finally
{
fclose($urlFP);
fclose($srcFP);
}
try
{
$this->setContentFromPath($srcPath);
}
finally
{
if (file_exists($srcPath))
unlink($srcPath);
}
}
public function getEditToken()
{
$x = [];
foreach ($this->getTags() as $tag)
$x []= TextHelper::reprTag($tag->name);
foreach ($this->getRelations() as $relatedPost)
$x []= TextHelper::reprPost($relatedPost);
$x []= $this->safety;
$x []= $this->source;
$x []= $this->fileHash;
natcasesort($x);
$x = join(' ', $x);
return md5($x);
}
}

View File

@ -0,0 +1,17 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class TagEntity extends AbstractEntity
{
public $name;
public function getPostCount()
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setTable('post_tag');
$stmt->setCriterion(new Sql\EqualsFunctor('tag_id', new Sql\Binding($this->id)));
return Database::fetchOne($stmt)['count'];
}
}

View File

@ -0,0 +1,18 @@
<?php
class TokenEntity extends AbstractEntity
{
public $userId;
public $token;
public $used;
public $expires;
public function getUser()
{
return UserModel::findById($this->userId);
}
public function setUser($user)
{
$this->userId = $user ? $user->id : null;
}
}

View File

@ -0,0 +1,166 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class UserEntity extends AbstractEntity
{
public $name;
public $passSalt;
public $passHash;
public $staffConfirmed;
public $emailUnconfirmed;
public $emailConfirmed;
public $joinDate;
public $lastLoginDate;
public $accessRank;
public $settings;
public $banned;
public function getAvatarUrl($size = 32)
{
$subject = !empty($this->emailConfirmed)
? $this->emailConfirmed
: $this->passSalt . $this->name;
$hash = md5(strtolower(trim($subject)));
$url = 'http://www.gravatar.com/avatar/' . $hash . '?s=' . $size . '&d=retro';
return $url;
}
public function getSetting($key)
{
$settings = json_decode($this->settings, true);
return isset($settings[$key])
? $settings[$key]
: null;
}
public function setSetting($key, $value)
{
$settings = json_decode($this->settings, true);
$settings[$key] = $value;
$settings = json_encode($settings);
if (strlen($settings) > 200)
throw new SimpleException('Too much data');
$this->settings = $settings;
}
public function hasEnabledSafety($safety)
{
$all = $this->getSetting(UserModel::SETTING_SAFETY);
if (!$all)
return $safety == PostSafety::Safe;
return $all & PostSafety::toFlag($safety);
}
public function enableSafety($safety, $enabled)
{
$all = $this->getSetting(UserModel::SETTING_SAFETY);
if (!$all)
$all = PostSafety::toFlag(PostSafety::Safe);
$new = $all;
if (!$enabled)
{
$new &= ~PostSafety::toFlag($safety);
if (!$new)
$new = PostSafety::toFlag(PostSafety::Safe);
}
else
{
$new |= PostSafety::toFlag($safety);
}
$this->setSetting(UserModel::SETTING_SAFETY, $new);
}
public function hasEnabledHidingDislikedPosts()
{
$ret = $this->getSetting(UserModel::SETTING_HIDE_DISLIKED_POSTS);
if ($ret === null)
$ret = !\Chibi\Registry::getConfig()->browsing->showDislikedPostsDefault;
return $ret;
}
public function enableHidingDislikedPosts($enabled)
{
$this->setSetting(UserModel::SETTING_HIDE_DISLIKED_POSTS, $enabled ? 1 : 0);
}
public function hasEnabledPostTagTitles()
{
$ret = $this->getSetting(UserModel::SETTING_POST_TAG_TITLES);
if ($ret === null)
$ret = \Chibi\Registry::getConfig()->browsing->showPostTagTitlesDefault;
return $ret;
}
public function enablePostTagTitles($enabled)
{
$this->setSetting(UserModel::SETTING_POST_TAG_TITLES, $enabled ? 1 : 0);
}
public function hasEnabledEndlessScrolling()
{
$ret = $this->getSetting(UserModel::SETTING_ENDLESS_SCROLLING);
if ($ret === null)
$ret = \Chibi\Registry::getConfig()->browsing->endlessScrollingDefault;
return $ret;
}
public function enableEndlessScrolling($enabled)
{
$this->setSetting(UserModel::SETTING_ENDLESS_SCROLLING, $enabled ? 1 : 0);
}
public function hasFavorited($post)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setTable('favoritee');
$stmt->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('user_id', new Sql\Binding($this->id)))
->add(new Sql\EqualsFunctor('post_id', new Sql\Binding($post->id))));
return Database::fetchOne($stmt)['count'] == 1;
}
public function getScore($post)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('score');
$stmt->setTable('post_score');
$stmt->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('user_id', new Sql\Binding($this->id)))
->add(new Sql\EqualsFunctor('post_id', new Sql\Binding($post->id))));
$row = Database::fetchOne($stmt);
if ($row)
return intval($row['score']);
return null;
}
public function getFavoriteCount()
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setTable('favoritee');
$stmt->setCriterion(new Sql\EqualsFunctor('user_id', new Sql\Binding($this->id)));
return Database::fetchOne($stmt)['count'];
}
public function getCommentCount()
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setTable('comment');
$stmt->setCriterion(new Sql\EqualsFunctor('commenter_id', new Sql\Binding($this->id)));
return Database::fetchOne($stmt)['count'];
}
public function getPostCount()
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setTable('post');
$stmt->setCriterion(new Sql\EqualsFunctor('uploader_id', new Sql\Binding($this->id)));
return Database::fetchOne($stmt)['count'];
}
}

View File

@ -4,4 +4,9 @@ class PostSafety extends Enum
const Safe = 1; const Safe = 1;
const Sketchy = 2; const Sketchy = 2;
const Unsafe = 3; const Unsafe = 3;
public static function toFlag($safety)
{
return pow(2, $safety);
}
} }

View File

@ -3,4 +3,5 @@ class PostType extends Enum
{ {
const Image = 1; const Image = 1;
const Flash = 2; const Flash = 2;
const Youtube = 3;
} }

View File

@ -10,9 +10,13 @@ class Privilege extends Enum
const EditPostTags = 7; const EditPostTags = 7;
const EditPostThumb = 8; const EditPostThumb = 8;
const EditPostSource = 26; const EditPostSource = 26;
const EditPostRelations = 30;
const EditPostFile = 36;
const HidePost = 9; const HidePost = 9;
const DeletePost = 10; const DeletePost = 10;
const FeaturePost = 25; const FeaturePost = 25;
const ScorePost = 31;
const FlagPost = 34;
const ListUsers = 11; const ListUsers = 11;
const ViewUser = 12; const ViewUser = 12;
@ -23,13 +27,20 @@ class Privilege extends Enum
const ChangeUserAccessRank = 16; const ChangeUserAccessRank = 16;
const ChangeUserEmail = 17; const ChangeUserEmail = 17;
const ChangeUserName = 18; const ChangeUserName = 18;
const ChangeUserSettings = 28;
const DeleteUser = 19; const DeleteUser = 19;
const FlagUser = 35;
const ListComments = 20; const ListComments = 20;
const AddComment = 23; const AddComment = 23;
const DeleteComment = 24; const DeleteComment = 24;
const EditComment = 37;
const ListTags = 21; const ListTags = 21;
const MergeTags = 27; const MergeTags = 27;
const RenameTags = 27; const RenameTags = 27;
const MassTag = 29;
const ListLogs = 32;
const ViewLog = 33;
} }

5
src/Models/IModel.php Normal file
View File

@ -0,0 +1,5 @@
<?php
interface IModel
{
static function getTableName();
}

View File

@ -1,30 +0,0 @@
<?php
class Model_Comment extends RedBean_SimpleModel
{
public static function locate($key)
{
$comment = R::findOne('comment', 'id = ?', [$key]);
if (!$comment)
throw new SimpleException('Invalid comment ID "' . $key . '"');
return $comment;
}
public static function validateText($text)
{
$text = trim($text);
$config = \Chibi\Registry::getConfig();
if (strlen($text) < $config->comments->minLength)
throw new SimpleException(sprintf('Comment must have at least %d characters', $config->comments->minLength));
if (strlen($text) > $config->comments->maxLength)
throw new SimpleException(sprintf('Comment must have at most %d characters', $config->comments->maxLength));
return $text;
}
public function getText()
{
return TextHelper::parseMarkdown($this->text);
}
}

View File

@ -1,41 +0,0 @@
<?php
class Model_Post extends RedBean_SimpleModel
{
public static function locate($key, $disallowNumeric = false)
{
if (is_numeric($key) and !$disallowNumeric)
{
$post = R::findOne('post', 'id = ?', [$key]);
if (!$post)
throw new SimpleException('Invalid post ID "' . $key . '"');
}
else
{
$post = R::findOne('post', 'name = ?', [$key]);
if (!$post)
throw new SimpleException('Invalid post name "' . $key . '"');
}
return $post;
}
public static function validateSafety($safety)
{
$safety = intval($safety);
if (!in_array($safety, PostSafety::getAll()))
throw new SimpleException('Invalid safety type "' . $safety . '"');
return $safety;
}
public static function validateSource($source)
{
$source = trim($source);
$maxLength = 100;
if (strlen($source) > $maxLength)
throw new SimpleException('Source must have at most ' . $maxLength . ' characters');
return $source;
}
}

View File

@ -1,37 +0,0 @@
<?php
class Model_Property extends RedBean_SimpleModel
{
const FeaturedPostId = 0;
const FeaturedPostUserId = 1;
const FeaturedPostDate = 2;
static $allProperties = null;
public static function get($propertyId)
{
if (self::$allProperties === null)
{
self::$allProperties = [];
foreach (R::find('property') as $prop)
{
self::$allProperties[$prop->prop_id] = $prop->value;
}
}
return isset(self::$allProperties[$propertyId])
? self::$allProperties[$propertyId]
: null;
}
public static function set($propertyId, $value)
{
$row = R::findOne('property', 'prop_id = ?', [$propertyId]);
if (!$row)
{
$row = R::dispense('property');
$row->prop_id = $propertyId;
}
$row->value = $value;
self::$allProperties[$propertyId] = $value;
R::store($row);
}
}

View File

@ -1,61 +0,0 @@
<?php
class Model_Tag extends RedBean_SimpleModel
{
public static function locate($key)
{
$user = R::findOne('tag', 'name = ?', [$key]);
if (!$user)
throw new SimpleException('Invalid tag name "' . $key . '"');
return $user;
}
public static function insertOrUpdate($tags)
{
$dbTags = [];
foreach ($tags as $tag)
{
$dbTag = R::findOne('tag', 'name = ?', [$tag]);
if (!$dbTag)
{
$dbTag = R::dispense('tag');
$dbTag->name = $tag;
R::store($dbTag);
}
$dbTags []= $dbTag;
}
return $dbTags;
}
public static function validateTag($tag)
{
$tag = trim($tag);
$minLength = 1;
$maxLength = 64;
if (strlen($tag) < $minLength)
throw new SimpleException('Tag must have at least ' . $minLength . ' characters');
if (strlen($tag) > $maxLength)
throw new SimpleException('Tag must have at most ' . $maxLength . ' characters');
if (!preg_match('/^[a-zA-Z0-9_-]+$/i', $tag))
throw new SimpleException('Invalid tag "' . $tag . '"');
return $tag;
}
public static function validateTags($tags)
{
$tags = trim($tags);
$tags = preg_split('/[,;\s]+/', $tags);
$tags = array_filter($tags, function($x) { return $x != ''; });
$tags = array_unique($tags);
foreach ($tags as $key => $tag)
$tags[$key] = self::validateTag($tag);
if (empty($tags))
throw new SimpleException('No tags set');
return $tags;
}
}

View File

@ -1,138 +0,0 @@
<?php
class Model_User extends RedBean_SimpleModel
{
public static function locate($key)
{
$user = R::findOne('user', 'name = ?', [$key]);
if (!$user)
throw new SimpleException('Invalid user name "' . $key . '"');
return $user;
}
public function getAvatarUrl($size = 32)
{
$subject = !empty($this->email_confirmed)
? $this->email_confirmed
: $this->pass_salt . $this->name;
$hash = md5(strtolower(trim($subject)));
$url = 'http://www.gravatar.com/avatar/' . $hash . '?s=' . $size . '&d=retro';
return $url;
}
public function getSetting($key)
{
$settings = json_decode($this->settings, true);
return isset($settings[$key])
? $settings[$key]
: null;
}
public function setSetting($key, $value)
{
$settings = json_decode($this->settings, true);
$settings[$key] = $value;
$settings = json_encode($settings);
if (strlen($settings) > 200)
throw new SimpleException('Too much data');
$this->settings = $settings;
}
public function hasEnabledSafety($safety)
{
return $this->getSetting('safety-' . $safety) !== false;
}
public function enableSafety($safety, $enabled)
{
if (!$enabled)
{
$this->setSetting('safety-' . $safety, false);
$anythingEnabled = false;
foreach (PostSafety::getAll() as $safety)
if (self::hasEnabledSafety($safety))
$anythingEnabled = true;
if (!$anythingEnabled)
$this->setSetting('safety-' . PostSafety::Safe, true);
}
else
{
$this->setSetting('safety-' . $safety, true);
}
}
public static function validateUserName($userName)
{
$userName = trim($userName);
$dbUser = R::findOne('user', 'name = ?', [$userName]);
if ($dbUser !== null)
{
if (!$dbUser->email_confirmed and \Chibi\Registry::getConfig()->registration->needEmailForRegistering)
throw new SimpleException('User with this name is already registered and awaits e-mail confirmation');
if (!$dbUser->staff_confirmed and \Chibi\Registry::getConfig()->registration->staffActivation)
throw new SimpleException('User with this name is already registered and awaits staff confirmation');
throw new SimpleException('User with this name is already registered');
}
$userNameMinLength = intval(\Chibi\Registry::getConfig()->registration->userNameMinLength);
$userNameMaxLength = intval(\Chibi\Registry::getConfig()->registration->userNameMaxLength);
$userNameRegex = \Chibi\Registry::getConfig()->registration->userNameRegex;
if (strlen($userName) < $userNameMinLength)
throw new SimpleException(sprintf('User name must have at least %d characters', $userNameMinLength));
if (strlen($userName) > $userNameMaxLength)
throw new SimpleException(sprintf('User name must have at most %d characters', $userNameMaxLength));
if (!preg_match($userNameRegex, $userName))
throw new SimpleException('User name contains invalid characters');
return $userName;
}
public static function validatePassword($password)
{
$passMinLength = intval(\Chibi\Registry::getConfig()->registration->passMinLength);
$passRegex = \Chibi\Registry::getConfig()->registration->passRegex;
if (strlen($password) < $passMinLength)
throw new SimpleException(sprintf('Password must have at least %d characters', $passMinLength));
if (!preg_match($passRegex, $password))
throw new SimpleException('Password contains invalid characters');
return $password;
}
public static function validateEmail($email)
{
$email = trim($email);
if (!empty($email) and !TextHelper::isValidEmail($email))
throw new SimpleException('E-mail address appears to be invalid');
return $email;
}
public static function validateAccessRank($accessRank)
{
$accessRank = intval($accessRank);
if (!in_array($accessRank, AccessRank::getAll()))
throw new SimpleException('Invalid access rank type "' . $accessRank . '"');
if ($accessRank == AccessRank::Nobody)
throw new SimpleException('Cannot set special accesss rank "' . $accessRank . '"');
return $accessRank;
}
public static function hashPassword($pass, $salt2)
{
$salt1 = \Chibi\Registry::getConfig()->registration->salt;
return sha1($salt1 . $salt2 . $pass);
}
}

321
src/Models/PostModel.php Normal file
View File

@ -0,0 +1,321 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class PostModel extends AbstractCrudModel
{
protected static $config;
public static function getTableName()
{
return 'post';
}
public static function init()
{
self::$config = \Chibi\Registry::getConfig();
}
public static function spawn()
{
$post = new PostEntity;
$post->hidden = false;
$post->uploadDate = time();
do
{
$post->name = md5(mt_rand() . uniqid());
}
while (file_exists($post->getFullPath()));
return $post;
}
public static function save($post)
{
Database::transaction(function() use ($post)
{
self::forgeId($post);
$bindings = [
'type' => $post->type,
'name' => $post->name,
'orig_name' => $post->origName,
'file_hash' => $post->fileHash,
'file_size' => $post->fileSize,
'mime_type' => $post->mimeType,
'safety' => $post->safety,
'hidden' => $post->hidden,
'upload_date' => $post->uploadDate,
'image_width' => $post->imageWidth,
'image_height' => $post->imageHeight,
'uploader_id' => $post->uploaderId,
'source' => $post->source,
];
$stmt = new Sql\UpdateStatement();
$stmt->setTable('post');
foreach ($bindings as $key => $value)
$stmt->setColumn($key, new Sql\Binding($value));
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($post->id)));
Database::exec($stmt);
//tags
$tags = $post->getTags();
$stmt = new Sql\DeleteStatement();
$stmt->setTable('post_tag');
$stmt->setCriterion(new Sql\EqualsFunctor('post_id', new Sql\Binding($post->id)));
Database::exec($stmt);
foreach ($tags as $postTag)
{
$stmt = new Sql\InsertStatement();
$stmt->setTable('post_tag');
$stmt->setColumn('post_id', new Sql\Binding($post->id));
$stmt->setColumn('tag_id', new Sql\Binding($postTag->id));
Database::exec($stmt);
}
//relations
$relations = $post->getRelations();
$stmt = new Sql\DeleteStatement();
$stmt->setTable('crossref');
$binding = new Sql\Binding($post->id);
$stmt->setCriterion((new Sql\DisjunctionFunctor)
->add(new Sql\EqualsFunctor('post_id', $binding))
->add(new Sql\EqualsFunctor('post2_id', $binding)));
Database::exec($stmt);
foreach ($relations as $relatedPost)
{
$stmt = new Sql\InsertStatement();
$stmt->setTable('crossref');
$stmt->setColumn('post_id', new Sql\Binding($post->id));
$stmt->setColumn('post2_id', new Sql\Binding($relatedPost->id));
Database::exec($stmt);
}
});
}
public static function remove($post)
{
Database::transaction(function() use ($post)
{
$binding = new Sql\Binding($post->id);
$stmt = new Sql\DeleteStatement();
$stmt->setTable('post_score');
$stmt->setCriterion(new Sql\EqualsFunctor('post_id', $binding));
Database::exec($stmt);
$stmt->setTable('post_tag');
Database::exec($stmt);
$stmt->setTable('favoritee');
Database::exec($stmt);
$stmt->setTable('comment');
Database::exec($stmt);
$stmt->setTable('crossref');
$stmt->setCriterion((new Sql\DisjunctionFunctor)
->add(new Sql\EqualsFunctor('post_id', $binding))
->add(new Sql\EqualsFunctor('post_id', $binding)));
Database::exec($stmt);
$stmt->setTable('post');
$stmt->setCriterion(new Sql\EqualsFunctor('id', $binding));
Database::exec($stmt);
});
}
public static function findByName($key, $throw = true)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('*');
$stmt->setTable('post');
$stmt->setCriterion(new Sql\EqualsFunctor('name', new Sql\Binding($key)));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('Invalid post name "' . $key . '"');
return null;
}
public static function findByIdOrName($key, $throw = true)
{
if (is_numeric($key))
$post = self::findById($key, $throw);
else
$post = self::findByName($key, $throw);
return $post;
}
public static function findByHash($key, $throw = true)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('*');
$stmt->setTable('post');
$stmt->setCriterion(new Sql\EqualsFunctor('file_hash', new Sql\Binding($key)));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('Invalid post hash "' . $hash . '"');
return null;
}
public static function preloadComments($posts)
{
if (empty($posts))
return;
$postMap = [];
$tagsMap = [];
foreach ($posts as $post)
{
$postId = $post->id;
$postMap[$postId] = $post;
$commentMap[$postId] = [];
}
$postIds = array_unique(array_keys($postMap));
$stmt = new Sql\SelectStatement();
$stmt->setTable('comment');
$stmt->addColumn('comment.*');
$stmt->addColumn('post_id');
$stmt->setCriterion(Sql\InFunctor::fromArray('post_id', Sql\Binding::fromArray($postIds)));
$rows = Database::fetchAll($stmt);
foreach ($rows as $row)
{
if (isset($comments[$row['id']]))
continue;
unset($row['post_id']);
$comment = CommentModel::convertRow($row);
$comments[$row['id']] = $comment;
}
foreach ($rows as $row)
{
$postId = $row['post_id'];
$commentMap[$postId] []= $comments[$row['id']];
}
foreach ($commentMap as $postId => $comments)
$postMap[$postId]->setCache('comments', $comments);
}
public static function preloadTags($posts)
{
if (empty($posts))
return;
$postMap = [];
$tagsMap = [];
foreach ($posts as $post)
{
$postId = $post->id;
$postMap[$postId] = $post;
$tagsMap[$postId] = [];
}
$postIds = array_unique(array_keys($postMap));
$stmt = new Sql\SelectStatement();
$stmt->setTable('tag');
$stmt->addColumn('tag.*');
$stmt->addColumn('post_id');
$stmt->addInnerJoin('post_tag', new Sql\EqualsFunctor('post_tag.tag_id', 'tag.id'));
$stmt->setCriterion(Sql\InFunctor::fromArray('post_id', Sql\Binding::fromArray($postIds)));
$rows = Database::fetchAll($stmt);
foreach ($rows as $row)
{
if (isset($tags[$row['id']]))
continue;
unset($row['post_id']);
$tag = TagModel::convertRow($row);
$tags[$row['id']] = $tag;
}
foreach ($rows as $row)
{
$postId = $row['post_id'];
$tagsMap[$postId] []= $tags[$row['id']];
}
foreach ($tagsMap as $postId => $tags)
$postMap[$postId]->setCache('tags', $tags);
}
public static function validateSafety($safety)
{
$safety = intval($safety);
if (!in_array($safety, PostSafety::getAll()))
throw new SimpleException('Invalid safety type "' . $safety . '"');
return $safety;
}
public static function validateSource($source)
{
$source = trim($source);
$maxLength = 200;
if (strlen($source) > $maxLength)
throw new SimpleException('Source must have at most ' . $maxLength . ' characters');
return $source;
}
public static function validateThumbSize($width, $height)
{
$width = $width === null ? self::$config->browsing->thumbWidth : $width;
$height = $height === null ? self::$config->browsing->thumbHeight : $height;
$width = min(1000, max(1, $width));
$height = min(1000, max(1, $height));
return [$width, $height];
}
private static function getThumbPathTokenized($text, $name, $width = null, $height = null)
{
list ($width, $height) = self::validateThumbSize($width, $height);
return TextHelper::absolutePath(TextHelper::replaceTokens($text, [
'fullpath' => self::$config->main->thumbsPath . DS . $name,
'width' => $width,
'height' => $height]));
}
public static function getThumbCustomPath($name, $width = null, $height = null)
{
return self::getThumbPathTokenized('{fullpath}.custom', $name, $width, $height);
}
public static function getThumbDefaultPath($name, $width = null, $height = null)
{
return self::getThumbPathTokenized('{fullpath}-{width}x{height}.default', $name, $width, $height);
}
public static function getFullPath($name)
{
return TextHelper::absolutePath(self::$config->main->filesPath . DS . $name);
}
}
PostModel::init();

View File

@ -0,0 +1,90 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class PropertyModel implements IModel
{
const FeaturedPostId = 0;
const FeaturedPostUserName = 1;
const FeaturedPostDate = 2;
const DbVersion = 3;
static $allProperties = null;
static $loaded = false;
public static function getTableName()
{
return 'property';
}
public static function loadIfNecessary()
{
if (!self::$loaded)
{
self::$loaded = true;
self::$allProperties = [];
$stmt = new Sql\SelectStatement();
$stmt ->setColumn('*');
$stmt ->setTable('property');
foreach (Database::fetchAll($stmt) as $row)
self::$allProperties[$row['prop_id']] = $row['value'];
}
}
public static function get($propertyId)
{
self::loadIfNecessary();
return isset(self::$allProperties[$propertyId])
? self::$allProperties[$propertyId]
: null;
}
public static function set($propertyId, $value)
{
self::loadIfNecessary();
Database::transaction(function() use ($propertyId, $value)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('id');
$stmt->setTable('property');
$stmt->setCriterion(new Sql\EqualsFunctor('prop_id', new Sql\Binding($propertyId)));
$row = Database::fetchOne($stmt);
if ($row)
{
$stmt = new Sql\UpdateStatement();
$stmt->setCriterion(new Sql\EqualsFunctor('prop_id', new Sql\Binding($propertyId)));
}
else
{
$stmt = new Sql\InsertStatement();
$stmt->setColumn('prop_id', new Sql\Binding($propertyId));
}
$stmt->setTable('property');
$stmt->setColumn('value', new Sql\Binding($value));
Database::exec($stmt);
self::$allProperties[$propertyId] = $value;
});
}
public static function featureNewPost()
{
$stmt = (new Sql\SelectStatement)
->setColumn('id')
->setTable('post')
->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('type', new Sql\Binding(PostType::Image)))
->add(new Sql\EqualsFunctor('safety', new Sql\Binding(PostSafety::Safe))))
->setOrderBy(new Sql\RandomFunctor(), Sql\SelectStatement::ORDER_DESC);
$featuredPostId = Database::fetchOne($stmt)['id'];
if (!$featuredPostId)
return null;
self::set(self::FeaturedPostId, $featuredPostId);
self::set(self::FeaturedPostDate, time());
self::set(self::FeaturedPostUserName, null);
return PostModel::findById($featuredPostId);
}
}

View File

@ -0,0 +1,101 @@
<?php
use \Chibi\Sql as Sql;
abstract class AbstractSearchParser
{
protected $statement;
public function decorate(Sql\SelectStatement $statement, $filterString)
{
$this->statement = $statement;
$tokens = preg_split('/\s+/', $filterString);
$tokens = array_filter($tokens);
$tokens = array_unique($tokens);
$this->processSetup($tokens);
foreach ($tokens as $token)
{
$neg = false;
if ($token{0} == '-')
{
$token = substr($token, 1);
$neg = true;
}
if (strpos($token, ':') !== false)
{
list ($key, $value) = explode(':', $token, 2);
$key = strtolower($key);
if ($key == 'order')
{
$this->internalProcessOrderToken($value, $neg);
}
else
{
if (!$this->processComplexToken($key, $value, $neg))
throw new SimpleException('Invalid search token: ' . $key);
}
}
else
{
if (!$this->processSimpleToken($token, $neg))
throw new SimpleException('Invalid search token: ' . $token);
}
}
$this->processTeardown();
}
protected function processSetup(&$tokens)
{
}
protected function processTeardown()
{
}
protected function internalProcessOrderToken($orderToken, $neg)
{
$arr = preg_split('/[;,]/', $orderToken);
if (count($arr) == 1)
$arr []= 'asc';
if (count($arr) != 2)
throw new SimpleException('Invalid search order token: ' . $orderToken);
$orderByString = strtolower(array_shift($arr));
$orderDirString = strtolower(array_shift($arr));
if ($orderDirString == 'asc')
$orderDir = Sql\SelectStatement::ORDER_ASC;
elseif ($orderDirString == 'desc')
$orderDir = Sql\SelectStatement::ORDER_DESC;
else
throw new SimpleException('Invalid search order direction: ' . $searchOrderDir);
if ($neg)
{
$orderDir = $orderDir == Sql\SelectStatement::ORDER_ASC
? Sql\SelectStatement::ORDER_DESC
: Sql\SelectStatement::ORDER_ASC;
}
if (!$this->processOrderToken($orderByString, $orderDir))
throw new SimpleException('Invalid search order type: ' . $orderByString);
}
protected function processComplexToken($key, $value, $neg)
{
return false;
}
protected function processSimpleToken($value, $neg)
{
return false;
}
protected function processOrderToken($orderToken, $orderDir)
{
return false;
}
}

View File

@ -0,0 +1,18 @@
<?php
use \Chibi\Sql as Sql;
class CommentSearchParser extends AbstractSearchParser
{
protected function processSetup(&$tokens)
{
$this->statement->addInnerJoin('post', new Sql\EqualsFunctor('post_id', 'post.id'));
$allowedSafety = PrivilegesHelper::getAllowedSafety();
$this->statement->setCriterion(new Sql\ConjunctionFunctor());
$this->statement->getCriterion()->add(Sql\InFunctor::fromArray('post.safety', Sql\Binding::fromArray($allowedSafety)));
if (!PrivilegesHelper::confirm(Privilege::ListPosts, 'hidden'))
$this->statement->getCriterion()->add(new Sql\NegationFunctor(new Sql\StringExpression('hidden')));
$this->statement->addOrderBy('comment.id', Sql\SelectStatement::ORDER_DESC);
}
}

View File

@ -0,0 +1,284 @@
<?php
use \Chibi\Sql as Sql;
class PostSearchParser extends AbstractSearchParser
{
private $tags;
private $showHidden = false;
private $showDisliked = false;
protected function processSetup(&$tokens)
{
$config = \Chibi\Registry::getConfig();
$this->tags = [];
$this->statement->setCriterion(new Sql\ConjunctionFunctor());
$allowedSafety = PrivilegesHelper::getAllowedSafety();
$this->statement->getCriterion()->add(Sql\InFunctor::fromArray('safety', Sql\Binding::fromArray($allowedSafety)));
if (count($tokens) > $config->browsing->maxSearchTokens)
throw new SimpleException('Too many search tokens (maximum: ' . $config->browsing->maxSearchTokens . ')');
}
protected function processTeardown()
{
if (\Chibi\Registry::getContext()->user->hasEnabledHidingDislikedPosts() and !$this->showDisliked)
$this->processComplexToken('special', 'disliked', true);
if (!PrivilegesHelper::confirm(Privilege::ListPosts, 'hidden') or !$this->showHidden)
$this->processComplexToken('special', 'hidden', true);
foreach ($this->tags as $item)
{
list ($tagName, $neg) = $item;
$tag = TagModel::findByName($tagName);
$innerStmt = new Sql\SelectStatement();
$innerStmt->setTable('post_tag');
$innerStmt->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_tag.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('post_tag.tag_id', new Sql\Binding($tag->id))));
$operator = new Sql\ExistsFunctor($innerStmt);
if ($neg)
$operator = new Sql\NegationFunctor($operator);
$this->statement->getCriterion()->add($operator);
}
$this->statement->addOrderBy('post.id',
empty($this->statement->getOrderBy())
? Sql\SelectStatement::ORDER_DESC
: $this->statement->getOrderBy()[0][1]);
}
protected function processSimpleToken($value, $neg)
{
$this->tags []= [$value, $neg];
return true;
}
protected function prepareCriterionForComplexToken($key, $value)
{
if (in_array($key, ['id', 'ids']))
{
$ids = preg_split('/[;,]/', $value);
$ids = array_map('intval', $ids);
return Sql\InFunctor::fromArray('post.id', Sql\Binding::fromArray($ids));
}
elseif (in_array($key, ['fav', 'favs']))
{
$user = UserModel::findByNameOrEmail($value);
$innerStmt = (new Sql\SelectStatement)
->setTable('favoritee')
->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('favoritee.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('favoritee.user_id', new Sql\Binding($user->id))));
return new Sql\ExistsFunctor($innerStmt);
}
elseif (in_array($key, ['comment', 'commenter']))
{
$user = UserModel::findByNameOrEmail($value);
$innerStmt = (new Sql\SelectStatement)
->setTable('comment')
->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('comment.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('comment.commenter_id', new Sql\Binding($user->id))));
return new Sql\ExistsFunctor($innerStmt);
}
elseif (in_array($key, ['submit', 'upload', 'uploader', 'uploaded']))
{
$user = UserModel::findByNameOrEmail($value);
return new Sql\EqualsFunctor('uploader_id', new Sql\Binding($user->id));
}
elseif (in_array($key, ['idmin', 'id_min']))
return new Sql\EqualsOrGreaterFunctor('post.id', new Sql\Binding(intval($value)));
elseif (in_array($key, ['idmax', 'id_max']))
return new Sql\EqualsOrLesserFunctor('post.id', new Sql\Binding(intval($value)));
elseif (in_array($key, ['scoremin', 'score_min']))
return new Sql\EqualsOrGreaterFunctor('score', new Sql\Binding(intval($value)));
elseif (in_array($key, ['scoremax', 'score_max']))
return new Sql\EqualsOrLesserFunctor('score', new Sql\Binding(intval($value)));
elseif (in_array($key, ['tagmin', 'tag_min']))
return new Sql\EqualsOrGreaterFunctor('tag_count', new Sql\Binding(intval($value)));
elseif (in_array($key, ['tagmax', 'tag_max']))
return new Sql\EqualsOrLesserFunctor('tag_count', new Sql\Binding(intval($value)));
elseif (in_array($key, ['favmin', 'fav_min']))
return new Sql\EqualsOrGreaterFunctor('fav_count', new Sql\Binding(intval($value)));
elseif (in_array($key, ['favmax', 'fav_max']))
return new Sql\EqualsOrLesserFunctor('fav_count', new Sql\Binding(intval($value)));
elseif (in_array($key, ['commentmin', 'comment_min']))
return new Sql\EqualsOrGreaterFunctor('comment_count', new Sql\Binding(intval($value)));
elseif (in_array($key, ['commentmax', 'comment_max']))
return new Sql\EqualsOrLesserFunctor('comment_count', new Sql\Binding(intval($value)));
elseif (in_array($key, ['date']))
{
list ($dateMin, $dateMax) = self::parseDate($value);
return (new Sql\ConjunctionFunctor)
->add(new Sql\EqualsOrLesserFunctor('upload_date', new Sql\Binding($dateMax)))
->add(new Sql\EqualsOrGreaterFunctor('upload_date', new Sql\Binding($dateMin)));
}
elseif (in_array($key, ['datemin', 'date_min']))
{
list ($dateMin, $dateMax) = self::parseDate($value);
return new Sql\EqualsOrGreaterFunctor('upload_date', new Sql\Binding($dateMin));
}
elseif (in_array($key, ['datemax', 'date_max']))
{
list ($dateMin, $dateMax) = self::parseDate($value);
return new Sql\EqualsOrLesserFunctor('upload_date', new Sql\Binding($dateMax));
}
elseif ($key == 'special')
{
$context = \Chibi\Registry::getContext();
$value = strtolower($value);
if (in_array($value, ['fav', 'favs', 'favd', 'favorite', 'favorites']))
{
return $this->prepareCriterionForComplexToken('fav', $context->user->name);
}
elseif (in_array($value, ['like', 'liked', 'likes']))
{
if (!$this->statement->isTableJoined('post_score'))
{
$this->statement->addLeftOuterJoin('post_score', (new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_score.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('post_score.user_id', new Sql\Binding($context->user->id))));
}
return new Sql\EqualsFunctor(new Sql\IfNullFunctor('post_score.score', '0'), '1');
}
elseif (in_array($value, ['dislike', 'disliked', 'dislikes']))
{
$this->showDisliked = true;
if (!$this->statement->isTableJoined('post_score'))
{
$this->statement->addLeftOuterJoin('post_score', (new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_score.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('post_score.user_id', new Sql\Binding($context->user->id))));
}
return new Sql\EqualsFunctor(new Sql\IfNullFunctor('post_score.score', '0'), '-1');
}
elseif ($value == 'hidden')
{
$this->showHidden = true;
return new Sql\StringExpression('hidden');
}
else
throw new SimpleException('Invalid special token: ' . $value);
}
elseif ($key == 'type')
{
$value = strtolower($value);
if ($value == 'swf')
$type = PostType::Flash;
elseif ($value == 'img')
$type = PostType::Image;
elseif ($value == 'yt' or $value == 'youtube')
$type = PostType::Youtube;
else
throw new SimpleException('Invalid post type: ' . $value);
return new Sql\EqualsFunctor('type', new Sql\Binding($type));
}
return null;
}
protected function processComplexToken($key, $value, $neg)
{
$criterion = $this->prepareCriterionForComplexToken($key, $value);
if (!$criterion)
return false;
if ($neg)
$criterion = new Sql\NegationFunctor($criterion);
$this->statement->getCriterion()->add($criterion);
return true;
}
protected function processOrderToken($orderByString, $orderDir)
{
$randomReset = true;
if (in_array($orderByString, ['id']))
$orderColumn = 'post.id';
elseif (in_array($orderByString, ['date']))
$orderColumn = 'upload_date';
elseif (in_array($orderByString, ['comment', 'comments', 'commentcount', 'comment_count']))
$orderColumn = 'comment_count';
elseif (in_array($orderByString, ['fav', 'favs', 'favcount', 'fav_count']))
$orderColumn = 'fav_count';
elseif (in_array($orderByString, ['score']))
$orderColumn = 'score';
elseif (in_array($orderByString, ['tag', 'tags', 'tagcount', 'tag_count']))
$orderColumn = 'tag_count';
elseif (in_array($orderByString, ['commentdate', 'comment_date']))
$orderColumn = 'comment_date';
elseif ($orderByString == 'random')
{
//seeding works like this: if you visit anything
//that triggers order other than random, the seed
//is going to reset. however, it stays the same as
//long as you keep visiting pages with order:random
//specified.
$randomReset = false;
if (!isset($_SESSION['browsing-seed']))
$_SESSION['browsing-seed'] = mt_rand();
$seed = $_SESSION['browsing-seed'];
$orderColumn = new Sql\SubstrFunctor(
new Sql\MultiplicationFunctor('post.id', $seed),
new Sql\AdditionFunctor(new Sql\LengthFunctor('post.id'), '2'));
}
else
return false;
if ($randomReset and isset($_SESSION['browsing-seed']))
unset($_SESSION['browsing-seed']);
$this->statement->setOrderBy($orderColumn, $orderDir);
return true;
}
protected static function parseDate($value)
{
list ($year, $month, $day) = explode('-', $value . '-0-0');
$yearMin = $yearMax = intval($year);
$monthMin = $monthMax = intval($month);
$monthMin = $monthMin ?: 1;
$monthMax = $monthMax ?: 12;
$dayMin = $dayMax = intval($day);
$dayMin = $dayMin ?: 1;
$dayMax = $dayMax ?: intval(date('t', mktime(0, 0, 0, $monthMax, 1, $year)));
$timeMin = mktime(0, 0, 0, $monthMin, $dayMin, $yearMin);
$timeMax = mktime(0, 0, -1, $monthMax, $dayMax+1, $yearMax);
return [$timeMin, $timeMax];
}
}

View File

@ -0,0 +1,39 @@
<?php
use \Chibi\Sql as Sql;
class TagSearchParser extends AbstractSearchParser
{
protected function processSetup(&$tokens)
{
$allowedSafety = PrivilegesHelper::getAllowedSafety();
$this->statement
->addInnerJoin('post_tag', new Sql\EqualsFunctor('tag.id', 'post_tag.tag_id'))
->addInnerJoin('post', new Sql\EqualsFunctor('post.id', 'post_tag.post_id'))
->setCriterion((new Sql\ConjunctionFunctor)->add(Sql\InFunctor::fromArray('safety', Sql\Binding::fromArray($allowedSafety))))
->setGroupBy('tag.id');
}
protected function processSimpleToken($value, $neg)
{
if ($neg)
return false;
if (strlen($value) >= 3)
$value = '%' . $value;
$value .= '%';
$this->statement->getCriterion()->add(new Sql\NoCaseFunctor(new Sql\LikeFunctor('tag.name', new Sql\Binding($value))));
return true;
}
protected function processOrderToken($orderByString, $orderDir)
{
if ($orderByString == 'popularity')
$this->statement->setOrderBy('post_count', $orderDir);
elseif ($orderByString == 'alpha')
$this->statement->setOrderBy('tag.name', $orderDir);
else
return false;
return true;
}
}

View File

@ -0,0 +1,32 @@
<?php
use \Chibi\Sql as Sql;
class UserSearchParser extends AbstractSearchParser
{
protected function processSimpleToken($value, $neg)
{
if ($neg)
return false;
if ($value == 'pending')
{
$this->statement->setCriterion((new Sql\DisjunctionFunctor)
->add(new Sql\IsFunctor('staff_confirmed', new Sql\NullFunctor()))
->add(new Sql\EqualsFunctor('staff_confirmed', '0')));
return true;
}
return false;
}
protected function processOrderToken($orderByString, $orderDir)
{
if ($orderByString == 'alpha')
$this->statement->setOrderBy(new Sql\NoCaseFunctor('name'), $orderDir);
elseif ($orderByString == 'date')
$this->statement->setOrderBy('join_date', $orderDir);
else
return false;
return true;
}
}

View File

@ -0,0 +1,79 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
abstract class AbstractSearchService
{
protected static function getModelClassName()
{
$searchServiceClassName = get_called_class();
$modelClassName = str_replace('SearchService', 'Model', $searchServiceClassName);
return $modelClassName;
}
protected static function getParserClassName()
{
$searchServiceClassName = get_called_class();
$parserClassName = str_replace('SearchService', 'SearchParser', $searchServiceClassName);
return $parserClassName;
}
protected static function decorateParser(Sql\SelectStatement $stmt, $searchQuery)
{
$parserClassName = self::getParserClassName();
(new $parserClassName)->decorate($stmt, $searchQuery);
}
protected static function decorateCustom(Sql\SelectStatement $stmt)
{
}
protected static function decoratePager(Sql\SelectStatement $stmt, $perPage, $page)
{
if ($perPage === null)
return;
$stmt->setLimit(
new Sql\Binding($perPage),
new Sql\Binding(($page - 1) * $perPage));
}
public static function getEntitiesRows($searchQuery, $perPage = null, $page = 1)
{
$modelClassName = self::getModelClassName();
$table = $modelClassName::getTableName();
$stmt = new Sql\SelectStatement();
$stmt->setColumn($table . '.*');
$stmt->setTable($table);
static::decorateParser($stmt, $searchQuery);
static::decorateCustom($stmt);
static::decoratePager($stmt, $perPage, $page);
return Database::fetchAll($stmt);
}
public static function getEntities($searchQuery, $perPage = null, $page = 1)
{
$modelClassName = self::getModelClassName();
$rows = static::getEntitiesRows($searchQuery, $perPage, $page);
return $modelClassName::convertRows($rows);
}
public static function getEntityCount($searchQuery)
{
$modelClassName = self::getModelClassName();
$table = $modelClassName::getTableName();
$innerStmt = new Sql\SelectStatement();
$innerStmt->setTable($table);
static::decorateParser($innerStmt, $searchQuery);
static::decorateCustom($innerStmt);
$innerStmt->resetOrderBy();
$stmt = new Sql\SelectStatement();
$stmt->setColumn(new Sql\AliasFunctor(new Sql\CountFunctor('1'), 'count'));
$stmt->setSource($innerStmt);
return Database::fetchOne($stmt)['count'];
}
}

View File

@ -0,0 +1,4 @@
<?php
class CommentSearchService extends AbstractSearchService
{
}

View File

@ -0,0 +1,50 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class PostSearchService extends AbstractSearchService
{
public static function getPostIdsAround($searchQuery, $postId)
{
return Database::transaction(function() use ($searchQuery, $postId)
{
$stmt = new Sql\RawStatement('CREATE TEMPORARY TABLE IF NOT EXISTS post_search(id INTEGER PRIMARY KEY, post_id INTEGER)');
Database::exec($stmt);
$stmt = new Sql\DeleteStatement();
$stmt->setTable('post_search');
Database::exec($stmt);
$innerStmt = new Sql\SelectStatement($searchQuery);
$innerStmt->setColumn('post.id');
$innerStmt->setTable('post');
self::decorateParser($innerStmt, $searchQuery);
$stmt = new Sql\InsertStatement();
$stmt->setTable('post_search');
$stmt->setSource(['post_id'], $innerStmt);
Database::exec($stmt);
$stmt = new Sql\SelectStatement();
$stmt->setTable('post_search');
$stmt->setColumn('id');
$stmt->setCriterion(new Sql\EqualsFunctor('post_id', new Sql\Binding($postId)));
$rowId = Database::fetchOne($stmt)['id'];
//it's possible that given post won't show in search results:
//it can be hidden, it can have prohibited safety etc.
if (!$rowId)
return [null, null];
$rowId = intval($rowId);
$stmt->setColumn('post_id');
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($rowId - 1)));
$nextPostId = Database::fetchOne($stmt)['post_id'];
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($rowId + 1)));
$prevPostId = Database::fetchOne($stmt)['post_id'];
return [$prevPostId, $nextPostId];
});
}
}

View File

@ -0,0 +1,23 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class TagSearchService extends AbstractSearchService
{
public static function decorateCustom(Sql\SelectStatement $stmt)
{
$stmt->addColumn(new Sql\AliasFunctor(new Sql\CountFunctor('post_tag.post_id'), 'post_count'));
}
public static function getMostUsedTag()
{
$stmt = new Sql\SelectStatement();
$stmt->setTable('post_tag');
$stmt->addColumn('tag_id');
$stmt->addColumn(new Sql\AliasFunctor(new Sql\CountFunctor('post_tag.post_id'), 'post_count'));
$stmt->setGroupBy('post_tag.tag_id');
$stmt->setOrderBy('post_count', Sql\SelectStatement::ORDER_DESC);
$stmt->setLimit(1, 0);
return Database::fetchOne($stmt);
}
}

View File

@ -0,0 +1,4 @@
<?php
class UserSearchService extends AbstractSearchService
{
}

189
src/Models/TagModel.php Normal file
View File

@ -0,0 +1,189 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class TagModel extends AbstractCrudModel
{
public static function getTableName()
{
return 'tag';
}
public static function save($tag)
{
Database::transaction(function() use ($tag)
{
self::forgeId($tag, 'tag');
$stmt = new Sql\UpdateStatement();
$stmt->setTable('tag');
$stmt->setColumn('name', new Sql\Binding($tag->name));
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($tag->id)));
Database::exec($stmt);
});
return $tag->id;
}
public static function remove($tag)
{
$binding = new Sql\Binding($tag->id);
$stmt = new Sql\DeleteStatement();
$stmt->setTable('post_tag');
$stmt->setCriterion(new Sql\EqualsFunctor('tag_id', $binding));
Database::exec($stmt);
$stmt = new Sql\DeleteStatement();
$stmt->setTable('tag');
$stmt->setCriterion(new Sql\EqualsFunctor('id', $binding));
Database::exec($stmt);
}
public static function rename($sourceName, $targetName)
{
Database::transaction(function() use ($sourceName, $targetName)
{
$sourceTag = TagModel::findByName($sourceName);
$targetTag = TagModel::findByName($targetName, false);
if ($targetTag and $targetTag->id != $sourceTag->id)
throw new SimpleException('Target tag already exists');
$sourceTag->name = $targetName;
self::save($sourceTag);
});
}
public static function merge($sourceName, $targetName)
{
Database::transaction(function() use ($sourceName, $targetName)
{
$sourceTag = TagModel::findByName($sourceName);
$targetTag = TagModel::findByName($targetName);
if ($sourceTag->id == $targetTag->id)
throw new SimpleException('Source and target tag are the same');
$stmt = new Sql\SelectStatement();
$stmt->setColumn('post.id');
$stmt->setTable('post');
$stmt->setCriterion(
(new Sql\ConjunctionFunctor)
->add(
new Sql\ExistsFunctor(
(new Sql\SelectStatement)
->setTable('post_tag')
->setCriterion(
(new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_tag.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('post_tag.tag_id', new Sql\Binding($sourceTag->id))))))
->add(
new Sql\NegationFunctor(
new Sql\ExistsFunctor(
(new Sql\SelectStatement)
->setTable('post_tag')
->setCriterion(
(new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_tag.post_id', 'post.id'))
->add(new Sql\EqualsFunctor('post_tag.tag_id', new Sql\Binding($targetTag->id))))))));
$rows = Database::fetchAll($stmt);
$postIds = array_map(function($row) { return $row['id']; }, $rows);
self::remove($sourceTag);
foreach ($postIds as $postId)
{
$stmt = new Sql\InsertStatement();
$stmt->setTable('post_tag');
$stmt->setColumn('post_id', new Sql\Binding($postId));
$stmt->setColumn('tag_id', new Sql\Binding($targetTag->id));
Database::exec($stmt);
}
});
}
public static function findAllByPostId($key)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('tag.*');
$stmt->setTable('tag');
$stmt->addInnerJoin('post_tag', new Sql\EqualsFunctor('post_tag.tag_id', 'tag.id'));
$stmt->setCriterion(new Sql\EqualsFunctor('post_tag.post_id', new Sql\Binding($key)));
$rows = Database::fetchAll($stmt);
if ($rows)
return self::convertRows($rows);
return [];
}
public static function findByName($key, $throw = true)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('tag.*');
$stmt->setTable('tag');
$stmt->setCriterion(new Sql\NoCaseFunctor(new Sql\EqualsFunctor('name', new Sql\Binding($key))));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('Invalid tag name "' . $key . '"');
return null;
}
public static function removeUnused()
{
$stmt = (new Sql\DeleteStatement)
->setTable('tag')
->setCriterion(
new Sql\NegationFunctor(
new Sql\ExistsFunctor(
(new Sql\SelectStatement)
->setTable('post_tag')
->setCriterion(new Sql\EqualsFunctor('post_tag.tag_id', 'tag.id')))));
Database::exec($stmt);
}
public static function validateTag($tag)
{
$tag = trim($tag);
$minLength = 1;
$maxLength = 64;
if (strlen($tag) < $minLength)
throw new SimpleException('Tag must have at least ' . $minLength . ' characters');
if (strlen($tag) > $maxLength)
throw new SimpleException('Tag must have at most ' . $maxLength . ' characters');
if (!preg_match('/^[()\[\]a-zA-Z0-9_.-]+$/i', $tag))
throw new SimpleException('Invalid tag "' . $tag . '"');
if (preg_match('/^\.\.?$/', $tag))
throw new SimpleException('Invalid tag "' . $tag . '"');
return $tag;
}
public static function validateTags($tags)
{
$tags = trim($tags);
$tags = preg_split('/[,;\s]+/', $tags);
$tags = array_filter($tags, function($x) { return $x != ''; });
$tags = array_unique($tags);
foreach ($tags as $key => $tag)
$tags[$key] = self::validateTag($tag);
if (empty($tags))
throw new SimpleException('No tags set');
return $tags;
}
}

79
src/Models/TokenModel.php Normal file
View File

@ -0,0 +1,79 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class TokenModel extends AbstractCrudModel
{
public static function getTableName()
{
return 'user_token';
}
public static function save($token)
{
Database::transaction(function() use ($token)
{
self::forgeId($token);
$bindings = [
'user_id' => $token->userId,
'token' => $token->token,
'used' => $token->used,
'expires' => $token->expires,
];
$stmt = new Sql\UpdateStatement();
$stmt->setTable('user_token');
$stmt->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($token->id)));
foreach ($bindings as $key => $val)
$stmt->setColumn($key, new Sql\Binding($val));
Database::exec($stmt);
});
}
public static function findByToken($key, $throw = true)
{
if (empty($key))
throw new SimpleNotFoundException('Invalid security token');
$stmt = new Sql\SelectStatement();
$stmt->setTable('user_token');
$stmt->setColumn('*');
$stmt->setCriterion(new Sql\EqualsFunctor('token', new Sql\Binding($key)));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('No user with such security token');
return null;
}
public static function checkValidity($token)
{
if (empty($token))
throw new SimpleException('Invalid security token');
if ($token->used)
throw new SimpleException('This token was already used');
if ($token->expires !== null and time() > $token->expires)
throw new SimpleException('This token has expired');
}
public static function forgeUnusedToken()
{
$tokenText = '';
while (true)
{
$tokenText = md5(mt_rand() . uniqid());
$token = self::findByToken($tokenText, false);
if (!$token)
return $tokenText;
}
}
}

259
src/Models/UserModel.php Normal file
View File

@ -0,0 +1,259 @@
<?php
use \Chibi\Sql as Sql;
use \Chibi\Database as Database;
class UserModel extends AbstractCrudModel
{
const SETTING_SAFETY = 1;
const SETTING_ENDLESS_SCROLLING = 2;
const SETTING_POST_TAG_TITLES = 3;
const SETTING_HIDE_DISLIKED_POSTS = 4;
public static function getTableName()
{
return 'user';
}
public static function spawn()
{
$user = new UserEntity();
$user->passSalt = md5(mt_rand() . uniqid());
return $user;
}
public static function save($user)
{
if ($user->accessRank == AccessRank::Anonymous)
throw new Exception('Trying to save anonymous user into database');
Database::transaction(function() use ($user)
{
self::forgeId($user);
$bindings = [
'name' => $user->name,
'pass_salt' => $user->passSalt,
'pass_hash' => $user->passHash,
'staff_confirmed' => $user->staffConfirmed,
'email_unconfirmed' => $user->emailUnconfirmed,
'email_confirmed' => $user->emailConfirmed,
'join_date' => $user->joinDate,
'last_login_date' => $user->lastLoginDate,
'access_rank' => $user->accessRank,
'settings' => $user->settings,
'banned' => $user->banned
];
$stmt = (new Sql\UpdateStatement)
->setTable('user')
->setCriterion(new Sql\EqualsFunctor('id', new Sql\Binding($user->id)));
foreach ($bindings as $key => $val)
$stmt->setColumn($key, new Sql\Binding($val));
Database::exec($stmt);
});
}
public static function remove($user)
{
Database::transaction(function() use ($user)
{
$binding = new Sql\Binding($user->id);
$stmt = new Sql\DeleteStatement();
$stmt->setTable('post_score');
$stmt->setCriterion(new Sql\EqualsFunctor('user_id', $binding));
Database::exec($stmt);
$stmt->setTable('favoritee');
Database::exec($stmt);
$stmt->setTable('user');
$stmt->setCriterion(new Sql\EqualsFunctor('id', $binding));
Database::exec($stmt);
$stmt = new Sql\UpdateStatement();
$stmt->setTable('comment');
$stmt->setCriterion(new Sql\EqualsFunctor('commenter_id', $binding));
$stmt->setColumn('commenter_id', new Sql\NullFunctor());
Database::exec($stmt);
$stmt = new Sql\UpdateStatement();
$stmt->setTable('post');
$stmt->setCriterion(new Sql\EqualsFunctor('uploader_id', $binding));
$stmt->setColumn('uploader_id', new Sql\NullFunctor());
Database::exec($stmt);
});
}
public static function findByName($key, $throw = true)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('*');
$stmt->setTable('user');
$stmt->setCriterion(new Sql\NoCaseFunctor(new Sql\EqualsFunctor('name', new Sql\Binding(trim($key)))));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('Invalid user name "' . $key . '"');
return null;
}
public static function findByNameOrEmail($key, $throw = true)
{
$stmt = new Sql\SelectStatement();
$stmt->setColumn('*');
$stmt->setTable('user');
$stmt->setCriterion((new Sql\DisjunctionFunctor)
->add(new Sql\NoCaseFunctor(new Sql\EqualsFunctor('name', new Sql\Binding(trim($key)))))
->add(new Sql\NoCaseFunctor(new Sql\EqualsFunctor('email_confirmed', new Sql\Binding(trim($key))))));
$row = Database::fetchOne($stmt);
if ($row)
return self::convertRow($row);
if ($throw)
throw new SimpleNotFoundException('Invalid user name "' . $key . '"');
return null;
}
public static function updateUserScore($user, $post, $score)
{
Database::transaction(function() use ($user, $post, $score)
{
$stmt = new Sql\DeleteStatement();
$stmt->setTable('post_score');
$stmt->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_id', new Sql\Binding($post->id)))
->add(new Sql\EqualsFunctor('user_id', new Sql\Binding($user->id))));
Database::exec($stmt);
$score = intval($score);
if ($score != 0)
{
$stmt = new Sql\InsertStatement();
$stmt->setTable('post_score');
$stmt->setColumn('post_id', new Sql\Binding($post->id));
$stmt->setColumn('user_id', new Sql\Binding($user->id));
$stmt->setColumn('score', new Sql\Binding($score));
Database::exec($stmt);
}
});
}
public static function addToUserFavorites($user, $post)
{
Database::transaction(function() use ($user, $post)
{
self::removeFromUserFavorites($user, $post);
$stmt = new Sql\InsertStatement();
$stmt->setTable('favoritee');
$stmt->setColumn('post_id', new Sql\Binding($post->id));
$stmt->setColumn('user_id', new Sql\Binding($user->id));
Database::exec($stmt);
});
}
public static function removeFromUserFavorites($user, $post)
{
Database::transaction(function() use ($user, $post)
{
$stmt = new Sql\DeleteStatement();
$stmt->setTable('favoritee');
$stmt->setCriterion((new Sql\ConjunctionFunctor)
->add(new Sql\EqualsFunctor('post_id', new Sql\Binding($post->id)))
->add(new Sql\EqualsFunctor('user_id', new Sql\Binding($user->id))));
Database::exec($stmt);
});
}
public static function validateUserName($userName)
{
$userName = trim($userName);
$dbUser = self::findByName($userName, false);
if ($dbUser !== null)
{
if (!$dbUser->emailConfirmed and \Chibi\Registry::getConfig()->registration->needEmailForRegistering)
throw new SimpleException('User with this name is already registered and awaits e-mail confirmation');
if (!$dbUser->staffConfirmed and \Chibi\Registry::getConfig()->registration->staffActivation)
throw new SimpleException('User with this name is already registered and awaits staff confirmation');
throw new SimpleException('User with this name is already registered');
}
$userNameMinLength = intval(\Chibi\Registry::getConfig()->registration->userNameMinLength);
$userNameMaxLength = intval(\Chibi\Registry::getConfig()->registration->userNameMaxLength);
$userNameRegex = \Chibi\Registry::getConfig()->registration->userNameRegex;
if (strlen($userName) < $userNameMinLength)
throw new SimpleException(sprintf('User name must have at least %d characters', $userNameMinLength));
if (strlen($userName) > $userNameMaxLength)
throw new SimpleException(sprintf('User name must have at most %d characters', $userNameMaxLength));
if (!preg_match($userNameRegex, $userName))
throw new SimpleException('User name contains invalid characters');
return $userName;
}
public static function validatePassword($password)
{
$passMinLength = intval(\Chibi\Registry::getConfig()->registration->passMinLength);
$passRegex = \Chibi\Registry::getConfig()->registration->passRegex;
if (strlen($password) < $passMinLength)
throw new SimpleException(sprintf('Password must have at least %d characters', $passMinLength));
if (!preg_match($passRegex, $password))
throw new SimpleException('Password contains invalid characters');
return $password;
}
public static function validateEmail($email)
{
$email = trim($email);
if (!empty($email) and !TextHelper::isValidEmail($email))
throw new SimpleException('E-mail address appears to be invalid');
return $email;
}
public static function validateAccessRank($accessRank)
{
$accessRank = intval($accessRank);
if (!in_array($accessRank, AccessRank::getAll()))
throw new SimpleException('Invalid access rank type "' . $accessRank . '"');
if ($accessRank == AccessRank::Nobody)
throw new SimpleException('Cannot set special accesss rank "' . $accessRank . '"');
return $accessRank;
}
public static function getAnonymousName()
{
return '[Anonymous user]';
}
public static function hashPassword($pass, $salt2)
{
$salt1 = \Chibi\Registry::getConfig()->main->salt;
return sha1($salt1 . $salt2 . $pass);
}
}

Some files were not shown because too many files have changed in this diff Show More