diff --git a/Dockerfile b/Dockerfile index 01baab9..1b67e0f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,39 @@ -FROM openresty/openresty:centos +FROM archlinux:latest + +RUN pacman -Sy base-devel lua51 sqlite luarocks redis geoip libmaxminddb tup git openssl-1.1 --noconfirm && \ + (yes | pacman -Scc || :) + +# setup openresty +ARG OPENRESTY_VERSION="1.21.4.2rc1" +RUN curl -O https://openresty.org/download/openresty-${OPENRESTY_VERSION}.tar.gz && \ + tar xvfz openresty-${OPENRESTY_VERSION}.tar.gz && \ + (cd openresty-${OPENRESTY_VERSION} && ./configure --with-pcre-jit --with-cc-opt="-I/usr/include/openssl-1.1" --with-ld-opt="-L/usr/lib/openssl-1.1" && make && make install) && \ + rm -rf openresty-${OPENRESTY_VERSION} && rm openresty-${OPENRESTY_VERSION}.tar.gz # Build Args ARG OPENSSL_DIR="/usr/local/openresty/openssl" # Environment -ENV LAPIS_ENV="production" +ENV LAPIS_ENV="development" # Prepare volumes VOLUME /var/data VOLUME /var/www -# Install from Yum -RUN yum -y install \ - epel-release \ - gcc \ - openresty-openssl-devel \ - openssl-devel \ - sqlite-devel \ - ; yum clean all - -RUN yum config-manager --set-enabled powertools - -# Install from LuaRocks -RUN luarocks install luasec -RUN luarocks install bcrypt -RUN luarocks install busted -RUN luarocks install i18n -RUN luarocks install lapis \ - CRYPTO_DIR=${OPENSSL_DIR} \ - CRYPTO_INCDIR=${OPENSSL_DIR}/include \ - OPENSSL_DIR=${OPENSSL_DIR} \ - OPENSSL_INCDIR=${OPENSSL_DIR}/include -RUN luarocks install lsqlite3 -RUN luarocks install luacov -RUN luarocks install mailgun -RUN luarocks install markdown +RUN eval $(luarocks --lua-version=5.1 path) +RUN export LUA_PATH="$LUA_PATH;/usr/local/openresty/lualib/?.lua" + +# install lua dependencies +COPY pagesix-dev-1.rockspec / +RUN luarocks --lua-version=5.1 build --tree "$HOME/.luarocks" --only-deps /pagesix-dev-1.rockspec # Entrypoint -ADD docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Standard web port (use a reverse proxy for SSL) EXPOSE 80 +WORKDIR /var/www + ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..c5c9762 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,48 @@ +FROM archlinux:latest + +RUN pacman -Sy base-devel lua51 sqlite luarocks redis geoip libmaxminddb tup git openssl-1.1 --noconfirm && \ + (yes | pacman -Scc || :) + +# setup openresty +ARG OPENRESTY_VERSION="1.21.4.2rc1" +RUN curl -O https://openresty.org/download/openresty-${OPENRESTY_VERSION}.tar.gz && \ + tar xvfz openresty-${OPENRESTY_VERSION}.tar.gz && \ + (cd openresty-${OPENRESTY_VERSION} && ./configure --with-pcre-jit --with-cc-opt="-I/usr/include/openssl-1.1" --with-ld-opt="-L/usr/lib/openssl-1.1" && make && make install) && \ + rm -rf openresty-${OPENRESTY_VERSION} && rm openresty-${OPENRESTY_VERSION}.tar.gz + +# Build Args +ARG OPENSSL_DIR="/usr/local/openresty/openssl" + +# Environment +ENV LAPIS_ENV="production" + +# Prepare volumes +VOLUME /var/data + +RUN eval $(luarocks --lua-version=5.1 path) +RUN export LUA_PATH="$LUA_PATH;/usr/local/openresty/lualib/?.lua" + +# install lua dependencies +COPY pagesix-dev-1.rockspec / +RUN luarocks --lua-version=5.1 build --tree "$HOME/.luarocks" --only-deps /pagesix-dev-1.rockspec + +# Entrypoint +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Standard web port (use a reverse proxy for SSL) +EXPOSE 80 + +WORKDIR /var/www + +COPY app/app.lua ./app.lua +COPY app/config.lua ./config.lua + +COPY app/migrations.lua ./migrations.lua +COPY app/mime.types ./mime.types +COPY app/nginx.conf ./nginx.conf + +COPY app/src ./src +COPY app/static ./static + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index eea59f5..fba19ec 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # Page Six -A better social link-sharing website. +A better social link-sharing site. ## TODO -- [ ] change the name - [ ] model relationships defined: - * 1 User -> 1 Comment - * many comments -> 1 Post - * many posts -> 1 Subreddit - * many subreddits -> 1 Subreddits listing + * 1 User -> 1 Comment + * many comments -> 1 Post + * many posts -> 1 Subreddit + * many subreddits -> 1 Subreddits listing - [ ] add [Constraints](https://leafo.net/lapis/reference/models.html#constraints) to models (?) - [ ] add table indexes (hot-sorted subreddit posts, homepage, user accounts) - [ ] user accounts w/[CSRF](https://leafo.net/lapis/reference/utilities.html#csrf-protection ) diff --git a/app/app.lua b/app/app.lua new file mode 100644 index 0000000..0756b1a --- /dev/null +++ b/app/app.lua @@ -0,0 +1,50 @@ +--- Page Six - A Reddit Clone +-- @script pagesix +-- @author Michael Burns +-- @license Apache License v2.0 + +local lapis = require "lapis" +local r2 = require("lapis.application").respond_to +local after_dispatch = require("lapis.nginx.context").after_dispatch +local to_json = require("lapis.util").to_json +local console = require("lapis.console") + +local app = lapis.Application() + +app:before_filter(function(self) + after_dispatch(function() + -- https://leafo.net/lapis/reference/configuration.html#performance-measurement + print(to_json(ngx.ctx.performance)) + end) +end) + +function app:default_route() + ngx.log(ngx.NOTICE, "User hit unknown path " .. self.req.parsed_url.path) + + -- call the original implementaiton to preserve the functionality it provides + return lapis.Application.default_route(self) +end + +function app:handle_404() + error("Failed to find route: " .. self.req.request_uri) + return { status = 404, layout = true, "Not Found!" } +end + +app:enable("etlua") + +app.layout = require "views.layout" + +app:match("homepage", "/", r2(require "actions.index")) +app:match("subreddits", "/subreddits(/:type)", r2(require "actions.subreddits")) +app:match("subreddit", "/r/:subreddit[%w](/:sort)", r2(require "actions.subreddit")) +app:match("post", "/r/:subreddit/comments/:post_id(/:title_stub)", r2(require "actions.post")) +app:match("comment", "/r/:subreddit/comments/:post_id/:title_stub/:comment_id(/:q)", r2(require "actions.comment")) + +app:match("/console", console.make()) -- only available in Development builds + +require("src.admin")(app) -- Admin endpoints +require("src.api")(app) -- API endpoints +require("src.auth")(app) -- User-authenticated endpoints +require("src.urls")(app) -- additional URLs + +return app diff --git a/app/config.lua b/app/config.lua new file mode 100644 index 0000000..e9d5218 --- /dev/null +++ b/app/config.lua @@ -0,0 +1,49 @@ +--- Pagesix config +-- @script pagesix.config + +local config = require "lapis.config" + +-- Maximum file size +local body_size = "1m" + +-- Path to your local project files +local lua_path = "./src/?.lua;./src/?/init.lua;./libs/?.lua;./libs/?/init.lua" +local lua_cpath = "" + +config("development", { + port = 80, + body_size = body_size, + lua_path = lua_path, + lua_cpath = lua_cpath, + server = "nginx", + code_cache = "off", + num_workers = "1", + name = "[DEVEL] Page Six", + session_name = "dev_app_session", + secret = "hunter42", -- TODO: manage Secrets + measure_performance = true, + sqlite = { + database = "/var/data/dev.sqlite" + } +}) + +config("production", { + port = 80, + body_size = body_size, + lua_path = lua_path, + lua_cpath = lua_cpath, + code_cache = "on", + server = "nginx", + num_workers = "3", + name = "Page Six", + session_name = "prod_app_session", + secret = os.getenv("LAPIS_SECRET"), + logging = { + requests = true, + queries = false, + server = true + }, + sqlite = { + database = "/var/data/production.sqlite" + } +}) diff --git a/app/libs/utilities.lua b/app/libs/utilities.lua new file mode 100644 index 0000000..e69de29 diff --git a/app/migrations.lua b/app/migrations.lua new file mode 100644 index 0000000..e0da46b --- /dev/null +++ b/app/migrations.lua @@ -0,0 +1,91 @@ +--- Migrations +-- @script migrations + +local db = require "lapis.db" +-- local schema = require("lapis.db.schema") +-- local types = schema.types +local json = require("cjson") +-- local misc = require("utils.misc") + +local Users = require("src.models.users") +local Pagesix = require("src.models.pagesix") +-- local Posts = require("src.models.posts") +local io = require("io") + +-- local Subreddit = require("src.models.subreddit") +local Subreddits = require("src.models.subreddits") + +-- add each incremental migration whose key is the unix timestamp +return { + -- create initial tables: Users, Subreddits + [1] = function() + Pagesix:bootstrap() + end, + + -- create first User + [2] = function() + Users:create({ + user_name = "anonymous_coward", + user_email = "anonymous@localhost", + user_pass = "hunter42!" + }) + end, + + -- create initial subreddits + [3] = function() + -- TODO figure out utils module + local data = {} + local path = "/var/data/initial_subs.json" + local file = io.open(path, "rb") + + if file then + local content = file:read "*a" -- *a or *all reads the whole file + file:close() + data = json.decode(content) + -- require 'pl.pretty'.dump(data) + -- print("Read in " .. #data .. " subreddits from " .. path) + end + + for _, sub in ipairs(data) do + -- print("About to create new sub: " .. sub.name .. ".") + local s, e = Subreddits:create({ + name = sub.name, + description = sub.description or "", + creator_id = sub.creator_id or 1, + }) + if not s then + print("error creating " .. s.name) + print(e) + end + Subreddits:create_db_tables(s.id) + end + end, + + [4] = function() + -- loop through all subreddits and create 10 posts for each + local subs = Subreddits:select() + for _, sub in ipairs(subs) do + print("About to create 10 posts for " .. sub.name .. ".") + local table_name = sub.id .. "_posts" + for i = 1, 10 do + print(i) + + local s, e = db.insert(table_name, { + title = "Post " .. i .. " for " .. sub.name, + permalink = "http://www.example.com/" .. i, + url = "http://www.example.com/" .. i, + user_id = 1, + }) + if not s then + print("error creating " .. s.title) + print(e) + break + end + end + end + + end, + + -- classify text : https://github.com/leafo/lapis-bayes + [1439944992] = require("lapis.bayes.schema").run_migrations, +} diff --git a/app/mime.types b/app/mime.types new file mode 100644 index 0000000..5d132eb --- /dev/null +++ b/app/mime.types @@ -0,0 +1,79 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/x-javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + image/svg+xml svg svgz; + image/webp webp; + + application/java-archive jar war ear; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.wap.wmlc wmlc; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream eot; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} diff --git a/app/nginx.conf b/app/nginx.conf new file mode 100644 index 0000000..c075ddd --- /dev/null +++ b/app/nginx.conf @@ -0,0 +1,74 @@ +# env PATH; +worker_processes ${{NUM_WORKERS}}; +error_log stderr notice; +daemon off; +pid logs/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include mime.types; + + client_max_body_size ${{BODY_SIZE}}; + client_body_buffer_size ${{BODY_SIZE}}; + + lua_package_path "${{LUA_PATH}};;"; + lua_package_cpath "${{LUA_CPATH}};;"; + + lua_shared_dict page_cache 15m; + + init_by_lua_block { + require "lpeg" + require "markdown" + } + + resolver 127.0.0.11; + resolver_timeout 4; + + map $http_user_agent $is_crawler { + default 0; + "~*crawl|Googlebot|Slurp|bingbot|Ahrefs|Yandex|ia_archiver|Applebot|ysearch|Baiduspider|Exabot|SemrushBot|SMTBot|facebookexternalhit|MegaIndex|PetalBot" 1; + } + + server { + listen ${{PORT}}; + lua_code_cache ${{CODE_CACHE}}; + + include nginx/http_proxy.conf; + + location / { + set $_url ""; + + if ($request_method = OPTIONS) { + add_header Content-Length 0; + add_header Content-Type text/plain; + access_log off; + return 200; + } + + default_type text/html; + + content_by_lua "require('lapis').serve('app')"; + } + + location /static/ { + alias static/; + # access_log off; + gzip on; + gzip_types application/x-javascript text/css image/svg+xml; + expires 3d; + gzip_comp_level 5; + } + + location /favicon.ico { + alias static/favicon.ico; + # access_log off; + } + + location /robots.txt { + alias static/robots.txt; + } + } +} diff --git a/app/nginx/http_proxy.conf b/app/nginx/http_proxy.conf new file mode 100644 index 0000000..6fba093 --- /dev/null +++ b/app/nginx/http_proxy.conf @@ -0,0 +1,35 @@ +location /proxy { + internal; + rewrite_by_lua " + local req = ngx.req + + for k,v in pairs(req.get_headers()) do + if k ~= 'content-length' then + req.clear_header(k) + end + end + + if ngx.ctx.headers then + for k,v in pairs(ngx.ctx.headers) do + req.set_header(k, v) + end + end + "; + + proxy_buffering on; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + proxy_http_version 1.1; + proxy_read_timeout 20s; + proxy_send_timeout 20s; + proxy_connect_timeout 10s; + + proxy_set_header Connection ""; + proxy_ssl_server_name on; + proxy_ssl_protocols TLSv1.2; + proxy_ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH'; + + proxy_pass $_url; +} diff --git a/app/spec/app_spec.lua b/app/spec/app_spec.lua new file mode 100644 index 0000000..2fcbc64 --- /dev/null +++ b/app/spec/app_spec.lua @@ -0,0 +1,19 @@ +--- Spec file for app.lua +-- @script app_spec + +local mock_request = require("lapis.spec.request").mock_request +local app = require("app") + +describe("readit", function() + require("lapis.spec").use_test_env() + + setup(function() + require("lapis.db.migrations").run_migrations(require("migrations")) + end) + + it("loads install page", function() + local status, body = mock_request(app, "/") + assert.same(200, status) + assert.truthy(body:find("Install Readit", 1, true)) + end) +end) diff --git a/app/src/actions/comment.lua b/app/src/actions/comment.lua new file mode 100644 index 0000000..18b2dc3 --- /dev/null +++ b/app/src/actions/comment.lua @@ -0,0 +1,11 @@ +--- Comment action +-- @module action.comment + +return { + before = function(self) + end, + + GET = function(self) + return { render = "comment" } + end +} diff --git a/app/src/actions/domain.lua b/app/src/actions/domain.lua new file mode 100644 index 0000000..f905f95 --- /dev/null +++ b/app/src/actions/domain.lua @@ -0,0 +1,12 @@ +--- Domain action +-- @module action.domain + +return { + before = function(self) + self.domain = self.params.domain + end, + + GET = function(self) + return { render = "domain" } + end +} diff --git a/app/src/actions/index.lua b/app/src/actions/index.lua new file mode 100644 index 0000000..5477026 --- /dev/null +++ b/app/src/actions/index.lua @@ -0,0 +1,11 @@ +--- Index action +-- @module action.index + +return { + before = function(self) + end, + + GET = function(self) + return { render = "index" } + end +} diff --git a/app/src/actions/post.lua b/app/src/actions/post.lua new file mode 100644 index 0000000..cbe7e69 --- /dev/null +++ b/app/src/actions/post.lua @@ -0,0 +1,36 @@ +--- Post action +-- @module action.post + +local db = require("lapis.db") + +return { + before = function(self) + -- Check if subreddit is nil or empty + local name = self.params.subreddit + if name == nil or name == '' then + print("Subreddit is unknown: " .. name) + return self:write({ redirect_to = self:url_for("homepage") }) + end + + local comments_table = name .. "_comments" + self.comments = db.select("* FROM ? WHERE post_id = ?", comments_table, self.params.post_id) + + print("Found " .. #self.comments .. " comments") + + local posts_table = name .. "_posts" + local post_data = db.select("* FROM ? WHERE id = ?", posts_table, self.params.post_id) + -- print("Post data:") + -- require 'pl.pretty'.dump(post_data[1]) + + self.user_id = post_data[1]['user_id'] + self.title = post_data[1]['title'] + self.url = post_data[1]['url'] + self.permalink = post_data[1]['permalink'] + self.created_utc = post_data[1]['created_utc'] + + end, + + GET = function(self) + return { render = "post" } + end +} diff --git a/app/src/actions/subreddit.lua b/app/src/actions/subreddit.lua new file mode 100644 index 0000000..1c33502 --- /dev/null +++ b/app/src/actions/subreddit.lua @@ -0,0 +1,74 @@ +--- Subreddit action +-- @module action.subreddit + +-- local assert_error = require("lapis.application").assert_error +-- local assert_valid = require("lapis.validate").assert_valid +-- local csrf = require "lapis.csrf" +local db = require "lapis.db" +local schema = require("lapis.db.schema") +-- local types = schema.types + +local Subreddits = require("models.subreddits") +local Sub_posts = require("models.subreddit_posts") +-- local Sub_post_votes = require("models.subreddit_votes") +-- local Sub_comments = require("models.subreddit_comments") + +return { + before = function(self) + -- query subreddits for id + print("MICHAEL " .. self.params.subreddit .. ".") + local res = db.select("id FROM 'subreddits' WHERE name=?", self.params.subreddit) + local id = res[1].id + print ("ID: " .. res[1].id) + -- local id = 2 -- TODO do not hardcode + + local posts_table = id .. "_posts" + local comments_table = id .. "_comments" + local votes_table = id .. "_votes" + local modlog_table = id .. "_modlog" + + print("posts table: " .. posts_table) + print("comments table: " .. comments_table) + print("votes table: " .. votes_table) + print("modlog table: " .. modlog_table) + + -- :check(name) + -- 1. check if it is valid + -- 2. check if it exists in Subreddits table + -- 3. check if its tables exist + -- 4. if not, create them + + -- Check if subreddit is nil or empty + -- if name == nil or name == '' then + -- print("Subreddit is unknown: " .. name) + -- return self:write({ redirect_to = self:url_for("homepage") }) + -- end + + -- Check if sub exist in Subreddits table + -- local should_exist = Subreddits:should_exist(name) + -- require 'pl.pretty'.dump(should_exist) + -- if next(should_exist) == nil then + -- print "Subreddit not found" + -- return self:write({ redirect_to = self:url_for("homepage") }) + -- end + + -- Check if tables exist + -- local does_exist = Subreddit:tables_exist(posts_table) + -- require 'pl.pretty'.dump(does_exist) + -- if next(does_exist) == nil then + -- Subreddit:new(name) + -- end + + -- self.posts = self:get_posts(posts_table) + self.posts = db.select("* FROM ?", posts_table) + end, + + -- https://github.com/karai17/lapis-chan/blob/master/app/src/utils/generate.lua + on_error = function(self) + return { render = "subreddit"} + end, + + GET = function(self) + return { render = "subreddit" } + end, +} diff --git a/app/src/actions/subreddits.lua b/app/src/actions/subreddits.lua new file mode 100644 index 0000000..e053aab --- /dev/null +++ b/app/src/actions/subreddits.lua @@ -0,0 +1,17 @@ +--- Subreddits action +-- @module action.subreddits + +local db = require "lapis.db" + +return { + before = function(self) + -- Get list of all subs + self.subs = db.select("* FROM ?", "subreddits") + + -- require 'pl.pretty'.dump(self.subs) + end, + + GET = function(self) + return { render = "subreddits" } + end, +} diff --git a/app/src/actions/user.lua b/app/src/actions/user.lua new file mode 100644 index 0000000..b1d819b --- /dev/null +++ b/app/src/actions/user.lua @@ -0,0 +1,24 @@ +--- User action +-- @module action.user + +local db = require "lapis.db" +local Users = require "models.users" + +return { + before = function(self) + + -- TODO lookup id from params.user_name + -- self.user = Users:find(params.user_name) + self.posts = user:get_posts_paginated({ + per_page = 50 + }):get_page(1) + + self.comments = user:get_comments_paginated({ + per_page = 50 + }):get_page(1) + end, + + GET = function(self) + return { render = "user" } + end +} diff --git a/app/src/admin.lua b/app/src/admin.lua new file mode 100644 index 0000000..da5afc4 --- /dev/null +++ b/app/src/admin.lua @@ -0,0 +1,9 @@ +function admin(app) + + -- TODO : all the things + app:get("/admin", function(self) return "Go away" end) + + return app +end + +return admin \ No newline at end of file diff --git a/app/src/api.lua b/app/src/api.lua new file mode 100644 index 0000000..76fcf32 --- /dev/null +++ b/app/src/api.lua @@ -0,0 +1,176 @@ +function api(app) + + app:get("/api", function(self) return "NOTE: The API doesn't work but most endpoints exist" end) + + -- NOTE: the following endpoints aren't included: + -- collections, emoji, flair, gold, listings, live threads, + -- private messages, new modmail, modnote, multis, widgets, wiki + + -- account + app:get("/api/v1/me", function(self) return "/api/v1/me" end) + app:get("/api/v1/me/blocked", function(self) return "/api/v1/me/blocked" end) + app:get("/api/v1/me/friends", function(self) return "/api/v1/me/friends" end) + app:get("/api/v1/me/karma", function(self) return "/api/v1/me/karma" end) + -- NOTE: lapis doens't support :patch + -- app:patch("/api/v1/me/prefs", function(self) return "/api/v1/me/prefs" end) + app:get("/api/v1/me/trophies", function(self) return "/api/v1/me/trophies" end) + app:get("/prefs/blocked", function(self) return "/prefs/blocked" end) + app:get("/prefs/friends", function(self) return "/prefs/friends" end) + app:get("/prefs/messaging", function(self) return "/prefs/messaging" end) + app:get("/prefs/trusted", function(self) return "/prefs/trusted" end) + app:get("/prefs/where", function(self) return "/prefs/where" end) + + -- captcha + app:get("/api/needs_captcha", function(self) return "/api/needs_captcha" end) + + -- links & comments + app:post("/api/comment", function(self) return "/api/comment" end) + app:post("/api/del", function(self) return "/api/del" end) + app:post("/api/editusertext", function(self) return "/api/editusertext" end) + app:post("/api/event_post_time", function(self) return "/api/event_post_time" end) + app:post("/api/follow_post", function(self) return "/api/follow_post" end) + app:post("/api/hide", function(self) return "/api/hide" end) + app:get("/api/info", function(self) return "/api/info" end) + app:post("/api/lock", function(self) return "/api/lock" end) + app:post("/api/marknsfw", function(self) return "/api/marknsfw" end) + app:get("/api/morechildren", function(self) return "/api/morechildren" end) + app:post("/api/report", function(self) return "/api/report" end) + app:post("/api/report_award", function(self) return "/api/report_award" end) + app:post("/api/save", function(self) return "/api/save" end) + app:get("/api/saved_categories", function(self) return "/api/saved_categories" end) + app:post("/api/sendreplies", function(self) return "/api/sendreplies" end) + app:post("/api/set_contest_mode", function(self) return "/api/set_contest_mode" end) + app:post("/api/set_subreddit_sticky", function(self) return "/api/set_subreddit_sticky" end) + app:post("/api/set_suggested_sort", function(self) return "/api/set_suggested_sort" end) + app:post("/api/spoiler", function(self) return "/api/spoiler" end) + app:post("/api/store_visits", function(self) return "/api/store_visits" end) + app:post("/api/submit", function(self) return "/api/submit" end) + app:post("/api/unhide", function(self) return "/api/unhide" end) + app:post("/api/unlock", function(self) return "/api/unlock" end) + app:post("/api/unmarknsfw", function(self) return "/api/unmarknsfw" end) + app:post("/api/unsave", function(self) return "/api/unsave" end) + app:post("/api/unspoiler", function(self) return "/api/unspoiler" end) + app:post("/api/vote", function(self) return "/api/vote" end) + + -- listings + app:get("/best", function(self) return "/best" end) + app:get("/by_id/names", function(self) return "/by_id/names" end) + app:get("/comments/article", function(self) return "/comments/article" end) + app:get("/controversial", function(self) return "/controversial" end) + app:get("/duplicates/article", function(self) return "/duplicates/article" end) + app:get("/hot", function(self) return "/hot" end) + app:get("/new", function(self) return "/new" end) + app:get("/random", function(self) return "/random" end) + app:get("/rising", function(self) return "/rising" end) + app:get("/top", function(self) return "/top" end) + app:get("/sort", function(self) return "/sort" end) + + -- misc + app:get("/api/saved_media_text", function(self) return "/api/saved_media_text" end) + app:get("/api/v1/scopes", function(self) return "/api/v1/scopes" end) + + -- moderation + app:get("/about/edited", function(self) return "/about/edited" end) + app:get("/about/log", function(self) return "/about/log" end) + app:get("/about/modqueue", function(self) return "/about/modqueue" end) + app:get("/about/reports", function(self) return "/about/reports" end) + app:get("/about/spam", function(self) return "/about/spam" end) + app:get("/about/unmoderated", function(self) return "/about/unmoderated" end) + app:get("/about/location", function(self) return "/about/location" end) + app:post("/api/accept_moderator_invite", function(self) return "/api/accept_moderator_invite" end) + app:post("/api/approve", function(self) return "/api/approve" end) + app:post("/api/distinguish", function(self) return "/api/distinguish" end) + app:post("/api/ignore_reports", function(self) return "/api/ignore_reports" end) + app:post("/api/leavecontributor", function(self) return "/api/leavecontributor" end) + app:post("/api/leavemoderator", function(self) return "/api/leavemoderator" end) + app:post("/api/mute_message_author", function(self) return "/api/mute_message_author" end) + app:post("/api/remove", function(self) return "/api/remove" end) + app:post("/api/show_comment", function(self) return "/api/show_comment" end) + app:post("/api/snooze_reports", function(self) return "/api/snooze_reports" end) + app:post("/api/unignore_reports", function(self) return "/api/unignore_reports" end) + app:post("/api/unmute_message_author", function(self) return "/api/unmute_message_author" end) + app:post("/api/unsnooze_reports", function(self) return "/api/unsnooze_reports" end) + app:post("/api/update_crowd_control_level", function(self) return "/api/update_crowd_control_level" end) + app:get("/stylesheet", function(self) return "/stylesheet" end) + + -- multis + app:get("/api/filter/filterpath", function(self) return "/api/filter/filterpath" end) + app:get("/api/filter/filterpath/r/srname", function(self) return "/api/filter/filterpath/r/srname" end) + app:post("/api/multi/copy", function(self) return "/api/multi/copy" end) + app:get("/api/multi/mine", function(self) return "/api/multi/mine" end) + app:get("/api/multi/user/username", function(self) return "/api/multi/user/username" end) + app:delete("/api/multi/multipath", function(self) return "/api/multi/multipath" end) + app:get("/api/multi/multipath", function(self) return "/api/multi/multipath" end) + app:post("/api/multi/multipath", function(self) return "/api/multi/multipath" end) + app:put("/api/multi/multipath", function(self) return "/api/multi/multipath" end) + app:get("/api/multi/multipath/description", function(self) return "/api/multi/multipath/description" end) + app:put("/api/multi/multipath/description", function(self) return "/api/multi/multipath/description" end) + app:delete("/api/multi/multipath/r/srname", function(self) return "/api/multi/multipath/r/srname" end) + app:get("/api/multi/multipath/r/srname", function(self) return "/api/multi/multipath/r/srname" end) + app:put("/api/multi/multipath/r/srname", function(self) return "/api/multi/multipath/r/srname" end) + + -- search + app:get("/search", function(self) return "/search" end) + + -- subreddits + app:get("/about/banned", function(self) return "/about/banned" end) + app:get("/about/contributors", function(self) return "/about/contributors" end) + app:get("/about/moderators", function(self) return "/about/moderators" end) + app:get("/about/muted", function(self) return "/about/muted" end) + app:get("/about/wikibanned", function(self) return "/about/wikibanned" end) + app:get("/about/wikicontributors", function(self) return "/about/wikicontributors" end) + app:get("/about/where", function(self) return "/about/where" end) + app:post("/api/delete_sr_banner", function(self) return "/api/delete_sr_banner" end) + app:post("/api/delete_sr_header", function(self) return "/api/delete_sr_header" end) + app:post("/api/delete_sr_icon", function(self) return "/api/delete_sr_icon" end) + app:post("/api/delete_sr_img", function(self) return "/api/delete_sr_img" end) + app:get("/api/recommend/sr/srnames", function(self) return "/api/recommend/sr/srnames" end) + app:post("/api/search_reddit_names", function(self) return "/api/search_reddit_names" end) + app:post("/api/search_subreddits", function(self) return "/api/search_subreddits" end) + app:post("/api/site_admin", function(self) return "/api/site_admin" end) + app:get("/api/submit_text", function(self) return "/api/submit_text" end) + app:get("/api/subreddit_autocomplete", function(self) return "/api/subreddit_autocomplete" end) + app:get("/api/subreddit_autocomplete_v2", function(self) return "/api/subreddit_autocomplete_v2" end) + app:post("/api/subreddit_stylesheet", function(self) return "/api/subreddit_stylesheet" end) + app:post("/api/subscribe", function(self) return "/api/subscribe" end) + app:post("/api/upload_sr_img", function(self) return "/api/upload_sr_img" end) + app:get("/api/v1/subreddit/post_requirements", function(self) return "/api/v1/subreddit/post_requirements" end) + app:get("/r/subreddit/about", function(self) return "/r/subreddit/about" end) + app:get("/r/subreddit/about/edit", function(self) return "/r/subreddit/about/edit" end) + app:get("/r/subreddit/about/rules", function(self) return "/r/subreddit/about/rules" end) + app:get("/r/subreddit/about/traffic", function(self) return "/r/subreddit/about/traffic" end) + app:get("/sidebar", function(self) return "/sidebar" end) + app:get("/sticky", function(self) return "/sticky" end) + app:get("/subreddits/default", function(self) return "/subreddits/default" end) + app:get("/subreddits/gold", function(self) return "/subreddits/gold" end) + app:get("/subreddits/mine/contributor", function(self) return "/subreddits/mine/contributor" end) + app:get("/subreddits/mine/moderator", function(self) return "/subreddits/mine/moderator" end) + app:get("/subreddits/mine/streams", function(self) return "/subreddits/mine/streams" end) + app:get("/subreddits/mine/subscriber", function(self) return "/subreddits/mine/subscriber" end) + app:get("/subreddits/mine/where", function(self) return "/subreddits/mine/where" end) + app:get("/subreddits/new", function(self) return "/subreddits/new" end) + app:get("/subreddits/popular", function(self) return "/subreddits/popular" end) + app:get("/subreddits/search", function(self) return "/subreddits/search" end) + app:get("/subreddits/where", function(self) return "/subreddits/where" end) + app:get("/users/new", function(self) return "/users/new" end) + app:get("/users/popular", function(self) return "/users/popular" end) + app:get("/users/search", function(self) return "/users/search" end) + app:get("/users/where", function(self) return "/users/where" end) + + -- users + app:post("/api/block_user", function(self) return "/api/block_user" end) + app:post("(/r/:subreddit)/api/friend", function(self) return "/api/friend" end) + app:post("/api/report_user", function(self) return "/api/report_user" end) + app:post("/api/setpermissions", function(self) return "/api/setpermissions" end) + app:post("(/r/:subreddit)/api/unfriend", function(self) return "/api/unfriend" end) + app:get("/api/user_data_by_account_ids", function(self) return "/api/user_data_by_account_ids" end) + app:get("/api/username_available", function(self) return "/api/username_available" end) + app:delete("/api/v1/me/friends/username", function(self) return "/api/v1/me/friends/username" end) + app:get("/api/v1/me/friends/username", function(self) return "/api/v1/me/friends/username" end) + app:put("/api/v1/me/friends/username", function(self) return "/api/v1/me/friends/username" end) + app:get("/api/v1/user/username/trophies", function(self) return "/api/v1/user/username/trophies" end) + app:get("/user/username/:where", function(self) return "/user/username/about" end) + + return app +end +return api \ No newline at end of file diff --git a/app/src/auth.lua b/app/src/auth.lua new file mode 100644 index 0000000..af1030e --- /dev/null +++ b/app/src/auth.lua @@ -0,0 +1,14 @@ +function auth(app) + + app:match("password", "/password", function(self) end) -- stub + app:match("login", "/login", function(self) end) -- stub + app:match("logout", "/logout", function(self) + -- Logout + self.session.user = nil + return { redirect_to = self:url_for("homepage") } + end) + + return app +end + +return auth \ No newline at end of file diff --git a/app/src/models.lua b/app/src/models.lua new file mode 100644 index 0000000..6e6e61b --- /dev/null +++ b/app/src/models.lua @@ -0,0 +1,4 @@ +--- Models +-- @module models + +return require("lapis.util").autoload "models" diff --git a/app/src/models/comments.lua b/app/src/models/comments.lua new file mode 100644 index 0000000..8e208a9 --- /dev/null +++ b/app/src/models/comments.lua @@ -0,0 +1,95 @@ +--- Comments model +-- @module models.comments + +local db = require "lapis.db" + +local Model = require("lapis.db.model").Model +local Comments = Model:extend("comments", { + constraints = { + --- Apply constraints when updating/adding a Comment, returns truthy to indicate error + -- @tparam table self + -- @tparam table value User data + -- @treturn string error + name = function(self, value) + + if string.len(value.body) > 4096 then + return "Comment must be less than 4096 characters" + end + if value.body == nil or value.body == "" then + return "Comment cannot be empty" + end + end + }, + relations = { + { "user", has_one="Users" }, + { "votes", has_many="Votes" }, + { "post", belongs_to="Posts" }, + -- { "parent_comment", belongs_to="Comments" }, + { "subreddit", belongs_to="Subreddits"} + } +}) + +--- Add a new comment +-- @tparam string params +-- @treturn boolean success +function Comments:new(params) + -- lookup + local comments_table = params.subreddit .. "_comments" + + db.insert(comments_table, { + post_id = params.post_id, + parent_comment_id = params.parent_comment_id, + body = params.body, + created_utc = params.created_utc, + is_submitter = params.is_submitter, + stickied = params.stickied, + }) +end + +--- Edit a comment +-- @tparam table params +-- @treturn table result +function Comments:modify(params) + local comments_table = params.board .. "_comments" + + local res = db.update(comments_table, { + edited = true, + body = params.body, + stickied = params.stickied, + }) + + return res +end + +--- Delete a comment +-- @tparam string subreddit_id +-- @tparam string comment_id +-- @treturn boolean success +function Comments:delete(subreddit_id, comment_id) + -- TODO: + + -- check if authorized? + local comment = Comments:find(comment_id) + return comment:delete() +end + +--- Get comments karma score +-- @tparam string post_id +-- @treturn number score +function Comments:get_score(post_id) + -- TODO: + + -- get board_id + -- check board_id_votes table + -- count upvotes and total rows + -- downvotes = total - upvotes + -- return upvotes - downvotes +end + +--- Check if comment is stickied to the Post +-- @tparam string comment_id +-- @treturn boolean stickied +function Comments:is_stickied(comment_id) + local comment = self:find(comment_id) + return comment.stickied +end diff --git a/app/src/models/pagesix.lua b/app/src/models/pagesix.lua new file mode 100644 index 0000000..547e99a --- /dev/null +++ b/app/src/models/pagesix.lua @@ -0,0 +1,91 @@ +--- Pagesix model +-- @module models.pagesix + +-- local db = require "lapis.db" +local schema = require("lapis.db.schema") +local create_index = schema.create_index +local types = schema.types + +local Model = require("lapis.db.model").Model +local Pagesix = Model:extend("pagesix", { + relations = { + -- { "subreddit", has_many="Pagesix" }, + { "moderator_ids", has_many="Users" }, + { "creator_id", has_one="Users" } + } +}) + + +function Pagesix:bootstrap() + Pagesix:create_users_table() + Pagesix:create_subscriptions_table() + Pagesix:create_reserved_usernames_table() + Pagesix:create_subreddits_table() +end + +function Pagesix:create_users_table() + schema.create_table("users", { + { "id", types.integer { unique=true, primary_key=true }}, + { "user_name", types.text { unique=true }}, + { "user_pass", types.text }, + { "user_email", types.text }, + + { "created_utc", types.integer { default="1970-01-01 00:00:00" }}, + { "deleted_utc", types.integer { null=true }}, + { "over_18", types.integer { default=false }}, + { "verified_email", types.integer { default=false }} + }) + + create_index("users", "user_name", { unique = true }) +end + +function Pagesix:create_subscriptions_table() + schema.create_table("subscriptions", { + { "id", types.integer { unique=true, primary_key=true }}, + { "user_id", types.integer }, + { "subreddit_id", types.integer } + }) +end + +function Pagesix:create_reserved_usernames_table() + schema.create_table("reserved_usernames", { + { "id", types.integer { unique=true, primary_key=true }}, + { "user_name", types.text { unique=true }}, + { "created_at", types.integer { default="1970-01-01 00:00:00" }}, + { "updated_at", types.integer { null=true }}, + }) +end + +function Pagesix:create_subreddits_table() + schema.create_table("subreddits", { + { "id", types.integer { unique=true, primary_key=true }}, + { "name", types.text { unique=true }}, + + { "created_at", types.integer { default="1970-01-01 00:00:00" }}, + { "deleted_at", types.integer { null=true }}, + { "updated_at", types.integer { null=true }}, + { "creator_id", types.integer { deafault=1 }}, + { "description", types.text { null=true }}, + { "moderator_ids", types.text { null=true }}, + { "nsfw", types.integer { default=false }} + }) + + -- create_index("subreddits", "name", { unique = true }) +end + +--- Get all subreddits +-- @treturn table subreddits +function Pagesix:get_all() + -- use Paginator + local subreddits = self:select("* FROM 'subreddits'") + return subreddits and subreddits or false, "FIXME: listing subreddits failed" +end + +--- Get all NSFW subreddits +-- @treturn table subreddits +function Pagesix:get_nsfw() + local subreddits = self:select("* FROM 'subreddits' WHERE nsfw=?", 1) + return subreddits and subreddits or false, "FIXME: listing NSFW subreddits failed" +end + +return Pagesix diff --git a/app/src/models/posts.lua b/app/src/models/posts.lua new file mode 100644 index 0000000..045a586 --- /dev/null +++ b/app/src/models/posts.lua @@ -0,0 +1,181 @@ +--- Posts model +-- @module models.posts + +local db = require "lapis.db" +-- local types = schema.types +-- local util = require("lapis.util") + +local Model = require("lapis.db.model").Model +local Posts = Model:extend("posts", { + relations = { + { "subreddit", belongs_to="Subreddits" }, + -- { "post", belongs_to="Posts" }, + { "comments", has_many="Comments" }, + { "votes", has_many="Votes" }, + { "user", belongs_to = "Users" } + } +}) + +--- Create a new post +-- @tparam table params Post parameters +-- @tparam table post post data +-- @tparam boolean op OP flag +-- @treturn boolean success +-- @treturn string error +-- function Posts:new(params, post, op) + +-- -- normalize url +-- if params.url then +-- params.url = util.normalize_url(params.url) +-- end + +-- -- TODO: url not posted to sub in last N days(?) + +-- -- TODO: title max length + +-- local post_table = post .. "_posts" + +-- local res, err = db.insert(post_table, { +-- user_id = params.user_id, +-- permalink = params.permalink, +-- title = params.title, +-- url = params.url, +-- locked = params.locked, +-- created_utc = params.created_utc, +-- is_self = params.is_self, +-- over_18 = params.over_18, +-- body = params.body +-- }) + +-- if not res then +-- return false, "FIXME: creating a post failed! " .. err +-- end +-- end + +--- Modify an existing post +-- @tparam table params Post parameters +-- @tparam string post_id Post ID +-- @treturn boolean success +-- function Posts:modify(params, post_id) +-- -- get subreddit from post_id +-- local subreddit = db.select("subreddit_id FROM posts WHERE id = ?", post_id) +-- local post_table = subreddit .. "_posts" + +-- return db.update(post_table, { +-- edited = true, +-- permalink = params.permalink, +-- title = params.title, +-- url = params.url, +-- locked = params.locked, +-- over_18 = params.over_18, +-- body = params.body, +-- }) +-- end + +--- Delete post data +-- @tparam integer subreddit_id Subreddit ID +-- @tparam integer post_id Post ID +-- @treturn boolean success +-- @treturn string error +-- function Posts:delete(subreddit_id, post_id) +-- local post = Posts:find(subreddit_id, post_id) +-- return post:delete() +-- end + +--- Get post data +-- @tparam number subreddit_id Subreddit ID +-- @tparam number post_id Local Post ID +-- @treturn table post +-- function Posts:get(subreddit_id, post_id) +-- local post = self:find { +-- subreddit_id = subreddit_id, +-- post_id = post_id +-- } +-- return post and post or false, "FIXME" +-- end + +--- Get post data +-- @tparam number id Post ID +-- @treturn table post +-- function Posts:get_post_by_id(id) +-- local post = self:find(id) +-- return post and post or false, "FIXME" +-- end + +--- Count comments in a post +-- @tparam integer post_id Post ID +-- @treturn integer posts +function Posts:count_comments(post_id) + local post = self:find(post_id) + return post:count("comments") +end + +--- Get posts in a thread +-- @tparam number post_id Post ID +-- @tparam number offset Offset +-- @tparam number limit Limit +-- @treturn table posts +function Posts:get_top_level_comments(post_id, offset, limit) + local post = self:find(post_id) + return post:get_comments(offset, limit) +end + +--- Get Post's karma score +function Posts:get_score(post_id, subreddit) + -- check subreddit is not nil + if subreddit == nil or subreddit == "" then + return false, "Invalid subreddit for post_id: " .. post_id + end + + local votes_table = subreddit .. "_votes" + -- select count(upvote) from ? where ? is null + local ups = db.select("SELECT count(*) FROM ? WHERE post_id = ? AND WHERE ? is not null", votes_table, post_id, user_id, upvote) + local downs = db.select("SELECT count(*) FROM ? WHERE post_id = ? AND WHERE ? is null", votes_table, post_id, user_id, upvote) + + if not ups or downs then + return false, "FIXME: getting score failed!" + end + + print(string.format("Post %s in %s has %s upvotes, %s downs.", post_id, subreddit, #ups, #downs)) + + return ups - downs +end + +--- Check if Post is locked +function Posts:is_locked(post_id) + local post = self:find(post_id) + return post.locked +end + +--- Check if Post is stickied +function Posts:is_stickied(post_id) + local post = self:find(post_id) + return post.stickied +end + +--- Check if Post is NSFW +function Posts:is_nsfw(post_id) + local post = self:find(post_id) + return post.over_18 +end + +--- Check if Post is a self post (no url, contains body text) +function Posts:is_self(post_id) + local post = self:find(post_id) + return post.is_self +end + +--- Given a table of post parameters, generate a permalink +-- @tparam table params Post parameters {post_id, user_id, title, url} +-- @treturn string permalink +function Posts:generate_permalink(params) + -- TODO: + + local subreddit_name = get Subreddit:subreddit_name(params.post_id) + local title_slug = utils.slugify(params.title) + local post_id = md5(title_slug .. params.user_id .. params.created_utc) + + return "/r/" .. subreddit_name .. "/comments/" .. params.post_id .. "/" .. title_slug +end + +return Posts diff --git a/app/src/models/subreddit_comments.lua b/app/src/models/subreddit_comments.lua new file mode 100644 index 0000000..53fc661 --- /dev/null +++ b/app/src/models/subreddit_comments.lua @@ -0,0 +1,2 @@ +--- Subreddits model +-- @module models.subreddit diff --git a/app/src/models/subreddit_modlog.lua b/app/src/models/subreddit_modlog.lua new file mode 100644 index 0000000..53fc661 --- /dev/null +++ b/app/src/models/subreddit_modlog.lua @@ -0,0 +1,2 @@ +--- Subreddits model +-- @module models.subreddit diff --git a/app/src/models/subreddit_posts.lua b/app/src/models/subreddit_posts.lua new file mode 100644 index 0000000..724127f --- /dev/null +++ b/app/src/models/subreddit_posts.lua @@ -0,0 +1,97 @@ +--- Subreddits model +-- @module models.subreddit + +-- local db = require "lapis.db" + +local Model = require("lapis.db.model").Model +-- local Silva = require 'silva' + +local id = 1 +-- local id +local sp_table = id .. "_posts" +print("The table be used is: " .. sp_table) + +local Subreddit_posts = Model:extend(sp_table, { + constraints = { + --- Apply constraints + -- @tparam table self + -- @tparam table value User data + -- @treturn string error + title = function(self, value) + -- title is maximum 256 characters + if string.len(value) > 256 then + return "Title is too long" + end + + -- title is minimum 2 characters + if string.len(value) < 1 then + return "Title is too short" + end + + end, + + url = function(self, value) + -- local uri, err = Silva:new(value) + + -- if not uri then + -- return("invalid URL: " .. err) + -- end + end, + + body = function(self, value) + if self.is_self and string.len(value) > 0 then + return "Only self posts can have body text" + end + + -- body text is maximum 16kb + if string.len(value) > 16384 then + return "Body text is too long" + end + end, + }, + + relations = { + { "user", belongs_to="Users"}, + + { "subreddit", has_one="Subreddits"}, + { "comments", + has_many="Comments", + where = {sub_id = id}, + order = "id desc", + key = "post_id" + }, + -- { "votes", + -- has_many="Votes", + -- where = {sub_id = id}, + -- order = "id desc", + -- key = "post_id" + -- }, + + -- { "top_posts", + -- has_many = "Posts", + -- where = {sub_id = id}, + -- order = "id desc", + -- key = "author" + -- }, + } +}) + +function Subreddit_posts:top_posts(subreddit_id) + -- query id .. "_posts" table for top 100 posts by score + + table = id .. "_posts" + + -- local upvotes = db.query("SELECT SUM(upvote) FROM '1_votes' WHERE post_id = '?' and upvote = '1'", post_id) + -- local downvotes = db.query("SELECT SUM(upvote) FROM '1_votes' WHERE post_id = '?' and upvote = '0'", post_id) + -- local votes = db.query("SELECT SUM(upvote) FROM '1_votes' WHERE post_id = '?'", post_id) + + local posts = db.query("SELECT *, COUNT(*) AS row_count FROM ? WHERE post_id = post_id, ORDER BY score DESC LIMIT 100", id .. table, subreddit_id) + + +end + +-- function Subreddit_posts:new_posts() +-- end + +-- function Subreddit_posts:controversial_posts() +-- end diff --git a/app/src/models/subreddit_votes.lua b/app/src/models/subreddit_votes.lua new file mode 100644 index 0000000..53fc661 --- /dev/null +++ b/app/src/models/subreddit_votes.lua @@ -0,0 +1,2 @@ +--- Subreddits model +-- @module models.subreddit diff --git a/app/src/models/subreddits.lua b/app/src/models/subreddits.lua new file mode 100644 index 0000000..2c1454c --- /dev/null +++ b/app/src/models/subreddits.lua @@ -0,0 +1,133 @@ +--- Subreddits model +-- @module models.subreddit + +local db = require "lapis.db" +-- local date = db.format_date() +local schema = require("lapis.db.schema") +local types = schema.types + +local Model = require("lapis.db.model").Model + +local Subreddits, Subreddits_mt = Model:extend("subreddits", { + primary_key = "id", + timestamp = true, + relations = { + { "posts", has_many="Posts" }, + { "moderators", has_many="Users" }, + { "creator", belongs_to="Users" }, + }, + + constraints = { + --- Apply constraints when updating/inserting a Subreddit row, returns truthy to indicate error + -- @tparam table self + -- @tparam table value User data + -- @treturn string error + name = function(self, value) + -- is subreddit name taken? + -- is subreddit not-empty? + + local reserved_subreddit_names = { + "all", + "popular", + "random", + "subscribed", + "unsubscribed", + } + if reserved_subreddit_names[value] then + return "Subreddit name is reserved" + end + + -- check for valid length (2-64] + if string.len(value) >= 64 then + return "Subreddits must be less than 64 characters" + end + + if string.len(value) < 2 then + return "Subreddits must be at least 2 characters" + end + end + }, +}) + +-- TODO check if name is name is in subreddits:get_all() +-- @param text name +-- @treturn table +function Subreddits:should_exist(name) + return db.query("SELECT name FROM subreddits WHERE name=?", name) +end + +--- Check if subreddit table(s) exist +-- @tparam string name +-- @treturn boolean +function Subreddits:tables_exist(name) + -- a subreddit has tables: $subreddit_{posts,comments,votes} + local table_name = name .. "_posts" + -- TODO ensure all tables exist + return db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table_name) +end + +--- Create a subreddit +-- @tparam string name Subreddit name +-- @treturn boolean success +-- @treturn string error +function Subreddits_mt:create_db_tables(id) + local posts_table = id .. "_posts" + local comments_table = id .. "_comments" + local votes_table = id .. "_votes" + local modlog_table = id .. "_modlog" + + -- create subreddit table containing Posts by Users + schema.create_table(posts_table, { + { "id", types.integer { unique=true, primary_key=true }}, + { "user_id", types.text }, + { "permalink", types.text { unique=true }}, + { "title", types.text }, + { "url", types.text }, + + { "locked", types.integer { default=false }}, + { "created_at", types.integer { default="1970-01-01 00:00:00" }}, + { "updated_at", types.integer { default="1970-01-01 00:00:00" }}, + { "edited", types.integer { default=false }}, + { "is_self", types.integer { default=false }}, + { "over_18", types.integer { default=false }}, + { "body", types.text { null=true }} + }) + + -- create subreddit table containing Comments by Users + schema.create_table(comments_table, { + { "id", types.integer { unique=true, primary_key=true }}, + { "post_id", types.integer }, + { "user_id", types.integer }, + { "parent_comment_id", types.integer { null=true }}, + { "body", types.text }, + + { "updated_at", types.integer { default="1970-01-01 00:00:00" }}, + { "edited", types.integer { default=false }}, + { "deleted", types.integer { default=false }}, + { "is_submitter", types.integer { default=false }}, + { "stickied", types.integer { default=false }} + }) + + -- create each subreddit table containing Votes on Posts or Comments by Users + schema.create_table(votes_table, { + { "id", types.integer { unique=true, primary_key=true }}, + { "user_id", types.integer }, + { "post_id", types.integer }, + { "comment_id", types.integer { null=true }}, + { "upvote", types.integer { default=true }} + }) + + schema.create_table(modlog_table, { + { "id", types.integer { unique=true, primary_key=true }}, + { "mod_id", types.text }, + { "user_id", types.text { null=true }}, + { "sub_id", types.text { null=true }}, + { "post_id", types.text { null=true }}, + { "comment_id", types.text { null=true }}, + { "action", types.integer { null=true }}, + { "reason", types.text }, + { "created_at", types.integer { default="1970-01-01 00:00:00" }} + }) +end + +return Subreddits diff --git a/app/src/models/subscriptions.lua b/app/src/models/subscriptions.lua new file mode 100644 index 0000000..7ea1695 --- /dev/null +++ b/app/src/models/subscriptions.lua @@ -0,0 +1,40 @@ +--- Subscriptions model +-- @module models.subscriptions + +local types = schema.types + +local Model = require("lapis.db.model").Model +local Subscriptions = Model:extend("subscriptions", { + relations = { + { "subreddit", belongs_to = "Subreddits" }, + { "user", belongs_to = "Users" } + } +}) + +-- TODO: {subreddit_id, user_id} tuple should be unique + +--- Subscribe to a subreddit +-- @tparam string subreddit_id +-- @treturn boolean success +function Subscriptions:subscribe(subreddit_id) + -- user_id = Users:find() + return Subscriptions:create({ + subreddit_id = subreddit_id, + user_id = user_id or 1 -- TODO can't hardcode this + }) +end + +function Subscriptions:unsubscribe(subreddit_id) + -- local user_id = Users:find() + local sub = Subscriptions:find({ + subreddit_id = subreddit_id, + user_id = user_id + }) + return Subscriptions:delete(sub) +end + +function Subscriptions:get_subscribed(user_id) + return Subscriptions:select("where user_id=?", user_id) +end + +return Subscriptions diff --git a/app/src/models/thread.lua b/app/src/models/thread.lua new file mode 100644 index 0000000..3aa4312 --- /dev/null +++ b/app/src/models/thread.lua @@ -0,0 +1,36 @@ +--- Thread model +-- @module models.thread + +local db = require "lapis.db" + +local Model = require("lapis.db.model").Model +local Thread = Model:extend("comments") + +-- comment_id, parent_comment_id, post_id + +--- Get a thread +-- @tparam string post_id +-- @tparam string comment_id +-- @tparam string parent_comment_id +-- @treturn table thread +function Thread:get(post_id, comment_id, parent_comment_id) + -- TODO: + + -- parse subreddit_name from post_id + + -- get comment + if params.parent_comment_id ~= nil then + Comments:get_comment(params.parent_comment_id) + end + + if params.post_id ~= nil then + get_post(params.post_id) + end + + local cmthrd = { + comment_id = params.comment_id, + parent_comment_id = params.parent_comment_id, + post_id = params.post_id, + } + return cmthrd and cmthrd or false, "FIXME: listing threads failed" +end diff --git a/app/src/models/users.lua b/app/src/models/users.lua new file mode 100644 index 0000000..ba2d08b --- /dev/null +++ b/app/src/models/users.lua @@ -0,0 +1,204 @@ +--- Users model +-- @module models.users + +-- local bcrypt = require "bcrypt" +local config = require("lapis.config").get() +local db = require "lapis.db" +local Model = require("lapis.db.model").Model +-- local token = config.secret +-- local util = require("lapis.util") +local json = require("cjson") + +-- local Users = Model:extend("users") +local Users = Model:extend("users", { + -- url_params = function(self, req, ...) + -- return "user_profile", { id = self.id }, ... + -- end, + + constraints = { + --- Apply constraints when updating/inserting a User row, returns truthy to indicate error + -- @tparam table self + -- @tparam table value User data + -- @treturn string error + user_name = function(self, value) + -- TODO : check if value is in reserved names + -- if db.find("reserved_usernames", { user_name = value }) then + -- return "Username is reserved" + -- end + + -- check for valid length (2-64] + if string.len(value) >= 64 then + return "Username must be less than 64 characters" + end + + if string.len(value) <= 2 then + return "Username must be more than 2 characters" + end + end, + + user_pass = function(self, value) + -- enforce password length requirements + local password_minimum_length = 8 + local password_maximum_length = 64 -- 4096 + if string.len(value) < password_minimum_length then + return string.format("Password must be at least %s characters", password_minimum_length) + end + if string.len(value) > password_maximum_length then + return string.format("Password must no more than %s characters", password_maximum_length) + end + end, + + user_email = function(self, value) + -- value must contain '@' + if not string.find(value, "@") then + return "Email must contain '@'" + end + end + }, + + relations = { + { "subscriptions", has_many="Subscriptions" }, + { "posts", has_many="Posts" }, + { "comments", has_many="Comments" }, + { "moderates", + has_many = "Subreddits", + order = "id desc", + key = "moderator_id" + }, + { "authored_posts", + has_many = "Posts", + -- where = {deleted = false}, + order = "id desc", + key = "user_id" + }, + { "authored_comments", + has_many = "Comments", + -- where = {deleted = false}, + order = "id desc", + key = "user_id" + } + } +}) + + +--- Create a new user +-- @tparam table params User data +-- @tparam string raw_password Raw password +-- @treturn boolean success +-- @treturn string error +function Users:new(params, raw_password) + + -- Check if username is unique + do + local unique, err = self:is_unique(params.user_name) + if not unique then return nil, err end + end + + -- TODO: Verify password + + + local user = Users:create(params) + return user and user or nil, { "err_create_user", { params.username } } +end + +--- Modify a user +-- @tparam table params User data +-- @tparam string raw_username Raw username +-- @tparam string raw_password Raw password +-- @treturn boolean success +-- @treturn string error +function Users:modify(params, raw_username, raw_password) + db.modify("users", params, {username = raw_username}) + + -- TODO: password? +end + +--- Delete user +-- @tparam table username User data +-- @treturn boolean success +-- @treturn string error +function Users:delete(username) + local user = self:get(username) + if not user then + return nil, "FIXME" + end + + -- tomebstone user + local success = user:update("users", + {deleted_tc = db.format_date()}, + {username = username}) + + return success and user or nil, "FIXME" +end + +--- Verify user +-- @tparam table params User data +-- @treturn boolean success +-- @treturn string error +-- function Users:login(params) + -- local user = self:get(params.username) + -- if not user then return nil, { "err_invalid_user" } end + + -- local password = user.username .. params.password .. token + -- local verified = bcrypt.verify(password, user.password) + + -- return verified and user or nil, { "err_invalid_user" } +-- end + +--- Get all users +-- @treturn table users List of users +function Users:get_all() + local users = db.select("order by username asc") + return users +end + +--- Get user's profile +-- @tparam string username Username +-- @treturn table user +-- function Users:get(username) + -- local users = db.select("* from 'users' where id=? limit 1", user_id) + -- return #users == 1 and users[1] or nil, "FIXME" +-- end + +--- Given a name, get the user's ID +-- @tparam string username +-- @treturn integer id +function Users:get_id(username) + local id = db.select("id FROM 'users' WHERE name=?", username) + return id and username or nil, "FIXME" +end + +--- Given an ID, get the user's name +-- @tparam integer id +-- @treturn string name +function Users:get_name(id) + local name = db.select("name from 'users' WHERE id = ?", id) + return name and id or nil, "FIXME" +end + +--- Check if a username is already taken +-- @tparam string username +-- @treturn boolean unique +function Users:is_unique(username) + local user = Users:count(username) + return not user and true or nil, "FIXME", user +end + +--- Validate if a password is valid +-- @tparam string password +-- @treturn string password +-- function Users.validate_password(password) +-- -- TODO: handle bcrypt password + +-- return password +-- end + +--- Given a User, return their subreddit subscriptions +-- @tparam table user +-- @treturn table subscriptions +function Users.get_subscriptions(user) + local subscriptions = db.select("* from 'subscriptions' where user_id=?", user.id) + return subscriptions +end + +return Users diff --git a/app/src/models/votes.lua b/app/src/models/votes.lua new file mode 100644 index 0000000..62091c8 --- /dev/null +++ b/app/src/models/votes.lua @@ -0,0 +1,13 @@ +--- Votes model +-- @module models.votes + +local Model = require("lapis.db.model").Model +local Votes = Model:extend("votes", { + relations = { + { "post", belongs_to = "Posts" }, + { "comment", belongs_to = "Comments" }, + { "user", belongs_to = "Users" } + } +}) + +-- TODO: {user_id, post_id, comment_id} tuple should be unique diff --git a/app/src/urls.lua b/app/src/urls.lua new file mode 100644 index 0000000..56f2484 --- /dev/null +++ b/app/src/urls.lua @@ -0,0 +1,27 @@ +local r2 = require("lapis.application").respond_to + +function urls(app) + + app:match("/domain/:domain", r2(require "actions.domain")) + + app:match("new", "/new", r2(require "actions.index")) + app:match("top", "/top", r2(require "actions.index")) + app:match("controversial", "/controversial", r2(require "actions.index")) + app:match("comments", "/comments", r2(require "actions.index")) + + app:match("all", "/r/all", function(self) end) -- stub + app:match("popular", "/r/popular", function(self) end) -- stub + app:match("random", "/r/random", function(self) end) -- stub + + app:match("profile", "/user/:user(/:type)", r2(require "actions.user")) + + app:match("submit", "/submit", function(self) end) -- stub + + app:match("about", "/about", function(self) end) -- stub + app:match("help", "/help", function(self) end) -- stub + app:match("contact", "/contact", function(self) end) -- stub + + return app +end + +return urls \ No newline at end of file diff --git a/app/src/utils/errors.lua b/app/src/utils/errors.lua new file mode 100644 index 0000000..20aa78c --- /dev/null +++ b/app/src/utils/errors.lua @@ -0,0 +1,107 @@ +--- Error handling utilities +-- @module utils.errors + +local ngx = _G.ngx +local get_error = {} +local status = {} + +--[[ API Error Codes ]]-- + +-- Authorization + +-- email:api_key format in Authorization HTTP header is invalid +function get_error.malformed_authorization() + return { code=100 } +end + +-- email:api_key in Authorization HTTP header does not match any user +-- login credentials do not match any user +function get_error.invalid_authorization() + return { code=101 } +end + +-- Attempting to access endpoint that requires higher priviliges +function get_error.unauthorized_access() + return { code=102 } +end + +-- Data Validation + +function get_error.field_not_found(field) + return { code=200, field=field } +end +function get_error.field_invalid(field) + return { code=201, field=field } +end +function get_error.field_not_unique(field) + return { code=202, field=field } +end +function get_error.token_expired(field) + return { code=203, field=field } +end +function get_error.password_not_match() + return { code=204 } +end + +-- Database I/O + +function get_error.database_unresponsive() + return { code=300 } +end +function get_error.database_create() + return { code=301 } +end +function get_error.database_modify() + return { code=302 } +end +function get_error.database_delete() + return { code=303 } +end +function get_error.database_select() + return { code=304 } +end + +--[[ API -> HTTP Code Map ]]-- + +-- Authorization +status[100] = ngx.HTTP_BAD_REQUEST +status[101] = ngx.HTTP_FORBIDDEN +status[102] = ngx.HTTP_UNAUTHORIZED + +-- Data Validation +status[200] = ngx.HTTP_BAD_REQUEST +status[201] = ngx.HTTP_BAD_REQUEST +status[202] = ngx.HTTP_BAD_REQUEST +status[203] = ngx.HTTP_BAD_REQUEST + +-- Database I/O +status[300] = ngx.HTTP_INTERNAL_SERVER_ERROR +status[301] = ngx.HTTP_INTERNAL_SERVER_ERROR +status[302] = ngx.HTTP_INTERNAL_SERVER_ERROR +status[303] = ngx.HTTP_INTERNAL_SERVER_ERROR +status[304] = ngx.HTTP_INTERNAL_SERVER_ERROR + +return { + get_error = get_error, + handle = function(self) + + -- Inject localized error messages + for _, err in ipairs(self.errors) do + --err.message = self.i18n(err.code) + if type(err) == "table" then + for k, v in pairs(err) do + print(k, ": ", v) + end + else + print(err) + end + end + + print(#self.errors) + + return self:write { + status = 401,--status[self.errors[1].code], + json = self.errors + } + end +} \ No newline at end of file diff --git a/app/src/utils/misc.lua b/app/src/utils/misc.lua new file mode 100644 index 0000000..d1a1b1b --- /dev/null +++ b/app/src/utils/misc.lua @@ -0,0 +1,50 @@ +--- Misc utils +-- @module utils.misc + +function misc(app) + + function File_exists(path) + local file = open(path, "rb") -- r read mode and b binary mode + if not file then return nil end + end + + function Misc:read_file(path) + local file = open(path, "rb") -- r read mode and b binary mode + if not file then return nil end + + local content = file:read "*a" -- *a or *all reads the whole file + file:close() + + return content + end + + function Generate_password() + local upperCase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + local lowerCase = "abcdefghijklmnopqrstuvwxyz" + local numbers = "0123456789" + local symbols = "!@#$%&*+-,./<=>?^" + + local characterSet = upperCase .. lowerCase .. numbers .. symbols + + local keyLength = 32 + local output = "" + + for i = 1, keyLength do + local rand = math.random(#characterSet) + output = output .. string.sub(characterSet, rand, rand) + end + return output + end + + function Validate_email(input) + if input:match(".+@.+%..+") then + return true + else + return false, "%s is not a valid email" + end + end + + return app +end + +return misc diff --git a/app/src/views/admin/admin.etlua b/app/src/views/admin/admin.etlua new file mode 100644 index 0000000..e69de29 diff --git a/app/src/views/admin/login.etlua b/app/src/views/admin/login.etlua new file mode 100644 index 0000000..e69de29 diff --git a/app/src/views/domain.etlua b/app/src/views/domain.etlua new file mode 100644 index 0000000..7124391 --- /dev/null +++ b/app/src/views/domain.etlua @@ -0,0 +1 @@ +List URLs submitted from the domain: <%= domain %> \ No newline at end of file diff --git a/app/src/views/fragments/comments.etlua b/app/src/views/fragments/comments.etlua new file mode 100644 index 0000000..2dbc23b --- /dev/null +++ b/app/src/views/fragments/comments.etlua @@ -0,0 +1,59 @@ +
<%= err %>
+<% end %> +Submitted by <%= user_id %> at <%= created_utc %>. Permalink
diff --git a/app/src/views/fragments/posts.etlua b/app/src/views/fragments/posts.etlua new file mode 100644 index 0000000..4ac20f3 --- /dev/null +++ b/app/src/views/fragments/posts.etlua @@ -0,0 +1,79 @@ +<% if posts ~= nil then + for n, post in ipairs(posts) do %> + + <% end %> +<% else %> + Nothing to see here. +<% end %> + diff --git a/app/src/views/fragments/search_pane.etlua b/app/src/views/fragments/search_pane.etlua new file mode 100644 index 0000000..bd9d693 --- /dev/null +++ b/app/src/views/fragments/search_pane.etlua @@ -0,0 +1,6 @@ +
+ [–] + <%= comment.user_id %> + + <% %># points + + + (# children) +
+ ++-
+ permalink
+
+ -
+ embed
+
+ -
+ save
+
+ -
+ report
+
+ -
+ give award
+
+
+
+ +