diff --git a/config.template.yaml b/config.template.yaml index 85ecd3d8..78782b1c 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -120,10 +120,14 @@ mail: # YouTube v3 API key # See https://developers.google.com/youtube/registering_an_application -# Google is closing the v2 API (which allowed anonymous requests) on -# April 20, 2015 so you must register a v3 API key now. -# NOTE: You must generate a Server key under Public API access, NOT a -# browser key. +# YouTube links will not work without this! +# Instructions: +# 1. Go to https://console.developers.google.com/project +# 2. Create a new API project +# 3. On the left sidebar, click "Credentials" under "APIs & auth" +# 4. Click "Create new Key" under "Public API access" +# 5. Click "Server key" +# 6. Under "APIs & auth" click "YouTube Data API" and then click "Enable API" youtube-v3-key: '' # Minutes between saving channel state to disk channel-save-interval: 5 @@ -202,6 +206,9 @@ channel-blacklist: [] # * ffmpeg must be installed on the server ffmpeg: enabled: false +# Executable name for ffprobe if it is not "ffprobe". On Debian and Ubuntu (on which +# libav is used rather than ffmpeg proper), this is "avprobe" + ffprobe-exec: 'ffprobe' link-domain-blacklist: [] diff --git a/lib/channel/chat.js b/lib/channel/chat.js index d7527344..a395d521 100644 --- a/lib/channel/chat.js +++ b/lib/channel/chat.js @@ -22,6 +22,12 @@ const TYPE_PM = { meta: "object,optional" }; +// Limit to 10 messages/sec +const MIN_ANTIFLOOD = { + burst: 20, + sustained: 10 +}; + function ChatModule(channel) { ChannelModule.apply(this, arguments); this.buffer = []; @@ -192,7 +198,13 @@ ChatModule.prototype.handlePm = function (user, data) { return; } - var msg = data.msg.substring(0, 240); + if (user.chatLimiter.throttle(MIN_ANTIFLOOD)) { + user.socket.emit("cooldown", 1000 / MIN_ANTIFLOOD.sustained); + return; + } + + + data.msg = data.msg.substring(0, 240); var to = null; for (var i = 0; i < this.channel.users.length; i++) { if (this.channel.users[i].getLowerName() === data.to) { @@ -216,7 +228,7 @@ ChatModule.prototype.handlePm = function (user, data) { } } - if (msg.indexOf(">") === 0) { + if (data.msg.indexOf(">") === 0) { meta.addClass = "greentext"; } @@ -243,13 +255,34 @@ ChatModule.prototype.processChatMsg = function (user, data) { } var msgobj = this.formatMessage(user.getName(), data); + var antiflood = MIN_ANTIFLOOD; if (this.channel.modules.options && this.channel.modules.options.get("chat_antiflood") && user.account.effectiveRank < 2) { - var antiflood = this.channel.modules.options.get("chat_antiflood_params"); - if (user.chatLimiter.throttle(antiflood)) { - user.socket.emit("cooldown", 1000 / antiflood.sustained); + antiflood = this.channel.modules.options.get("chat_antiflood_params"); + } + + if (user.chatLimiter.throttle(antiflood)) { + user.socket.emit("cooldown", 1000 / antiflood.sustained); + return; + } + + if (data.msg.indexOf(">") === 0) { + msgobj.meta.addClass = "greentext"; + } + + if (data.msg.indexOf("/") === 0) { + var space = data.msg.indexOf(" "); + var cmd; + if (space < 0) { + cmd = data.msg.substring(1); + } else { + cmd = data.msg.substring(1, space); + } + + if (cmd in this.commandHandlers) { + this.commandHandlers[cmd](user, data.msg, data.meta); return; } } @@ -270,27 +303,7 @@ ChatModule.prototype.processChatMsg = function (user, data) { }); return; } - - if (data.msg.indexOf("/") === 0) { - var space = data.msg.indexOf(" "); - var cmd; - if (space < 0) { - cmd = data.msg.substring(1); - } else { - cmd = data.msg.substring(1, space); - } - - if (cmd in this.commandHandlers) { - this.commandHandlers[cmd](user, data.msg, data.meta); - } else { - this.sendMessage(msgobj); - } - } else { - if (data.msg.indexOf(">") === 0) { - msgobj.meta.addClass = "greentext"; - } - this.sendMessage(msgobj); - } + this.sendMessage(msgobj); }; ChatModule.prototype.formatMessage = function (username, data) { diff --git a/lib/channel/opts.js b/lib/channel/opts.js index acdde7a0..45455324 100644 --- a/lib/channel/opts.js +++ b/lib/channel/opts.js @@ -39,6 +39,11 @@ OptionsModule.prototype.load = function (data) { } } } + + this.opts.chat_antiflood_params.burst = Math.min(20, + this.opts.chat_antiflood_params.burst); + this.opts.chat_antiflood_params.sustained = Math.min(10, + this.opts.chat_antiflood_params.sustained); }; OptionsModule.prototype.save = function (data) { @@ -216,11 +221,15 @@ OptionsModule.prototype.handleSetOptions = function (user, data) { b = 1; } + b = Math.min(20, b); + var s = parseFloat(data.chat_antiflood_params.sustained); if (isNaN(s) || s <= 0) { s = 1; } + s = Math.min(10, s); + var c = b / s; this.opts.chat_antiflood_params = { burst: b, diff --git a/lib/config.js b/lib/config.js index d549fe0a..8239b7fb 100644 --- a/lib/config.js +++ b/lib/config.js @@ -100,7 +100,8 @@ var defaults = { }, "channel-blacklist": [], ffmpeg: { - enabled: false + enabled: false, + "ffprobe-exec": "ffprobe" }, "link-domain-blacklist": [], setuid: { @@ -351,9 +352,8 @@ function preprocessConfig(cfg) { require("cytube-mediaquery/lib/provider/youtube").setApiKey( cfg["youtube-v3-key"]); } else { - Logger.errlog.log("Warning: No YouTube v3 API key set. YouTube lookups will " + - "fall back to the v2 API, which is scheduled for closure soon after " + - "April 20, 2015. See " + + Logger.errlog.log("Warning: No YouTube v3 API key set. YouTube links will " + + "not work. See youtube-v3-key in config.template.yaml and " + "https://developers.google.com/youtube/registering_an_application for " + "information on registering an API key."); } diff --git a/lib/database/accounts.js b/lib/database/accounts.js index 51f0a101..f270e5d7 100644 --- a/lib/database/accounts.js +++ b/lib/database/accounts.js @@ -7,6 +7,15 @@ var Logger = require("../logger"); var registrationLock = {}; var blackHole = function () { }; +/** + * Replaces look-alike characters with "_" (single character wildcard) for + * use in LIKE queries. This prevents guests from taking names that look + * visually identical to existing names in certain fonts. + */ +function wildcardSimilarChars(name) { + return name.replace(/[Il1oO0]/g, "_"); +} + module.exports = { init: function () { }, @@ -15,7 +24,7 @@ module.exports = { * Check if a username is taken */ isUsernameTaken: function (name, callback) { - db.query("SELECT name FROM `users` WHERE name=?", [name], + db.query("SELECT name FROM `users` WHERE name LIKE ?", [wildcardSimilarChars(name)], function (err, rows) { if (err) { callback(err, true); diff --git a/lib/ffmpeg.js b/lib/ffmpeg.js index 276e85b2..844fb369 100644 --- a/lib/ffmpeg.js +++ b/lib/ffmpeg.js @@ -1,6 +1,12 @@ var Logger = require("./logger"); var Config = require("./config"); var spawn = require("child_process").spawn; +var https = require("https"); +var http = require("http"); +var urlparse = require("url"); +var statusMessages = require("./status-messages"); + +var USE_JSON = true; var acceptedCodecs = { "mov/h264": true, @@ -19,6 +25,169 @@ var audioOnlyContainers = { "mp3": true }; +function testUrl(url, cb, redirCount) { + if (!redirCount) redirCount = 0; + var data = urlparse.parse(url); + if (!/https?:/.test(data.protocol)) { + return cb("Video links must start with http:// or https://"); + } + + if (!data.hostname) { + return cb("Invalid link"); + } + + var transport = (data.protocol === "https:") ? https : http; + data.method = "HEAD"; + var req = transport.request(data, function (res) { + req.abort(); + + if (res.statusCode === 301 || res.statusCode === 302) { + if (redirCount > 2) { + return cb("Too many redirects. Please provide a direct link to the " + + "file"); + } + return testUrl(res.headers["location"], cb, redirCount + 1); + } + + if (res.statusCode !== 200) { + var message = statusMessages[res.statusCode]; + if (!message) message = ""; + return cb("HTTP " + res.statusCode + " " + message); + } + + if (!/^audio|^video/.test(res.headers["content-type"])) { + return cb("Server did not return an audio or video file, or sent the " + + "wrong Content-Type"); + } + + cb(); + }); + + req.on("error", function (err) { + cb(err); + }); + + req.end(); +} + +function readOldFormat(buf) { + var lines = buf.split("\n"); + var tmp = { tags: {} }; + var data = { + streams: [] + }; + + lines.forEach(function (line) { + if (line.match(/\[stream\]|\[format\]/i)) { + return; + } else if (line.match(/\[\/stream\]/i)) { + data.streams.push(tmp); + tmp = { tags: {} }; + } else if (line.match(/\[\/format\]/i)) { + data.format = tmp; + tmp = { tags: {} }; + } else { + var kv = line.split("="); + var key = kv[0].toLowerCase(); + if (key.indexOf("tag:") === 0) { + tmp.tags[key.split(":")[1]] = kv[1]; + } else { + tmp[key] = kv[1]; + } + } + }); + + return data; +} + +function reformatData(data) { + var reformatted = {}; + + var duration = parseInt(data.format.duration, 10); + if (isNaN(duration)) duration = "--:--"; + reformatted.duration = Math.ceil(duration); + + var bitrate = parseInt(data.format.bit_rate, 10) / 1000; + if (isNaN(bitrate)) bitrate = 0; + reformatted.bitrate = bitrate; + + reformatted.title = data.format.tags ? data.format.tags.title : null; + var container = data.format.format_name.split(",")[0]; + + data.streams.forEach(function (stream) { + if (stream.codec_type === "video") { + reformatted.vcodec = stream.codec_name; + if (!reformatted.title && stream.tags) { + reformatted.title = stream.tags.title; + } + } else if (stream.codec_type === "audio") { + reformatted.acodec = stream.codec_name; + } + }); + + if (reformatted.vcodec && !(audioOnlyContainers.hasOwnProperty(container))) { + reformatted.type = [container, reformatted.vcodec].join("/"); + reformatted.medium = "video"; + } else if (reformatted.acodec) { + reformatted.type = [container, reformatted.acodec].join("/"); + reformatted.medium = "audio"; + } + + return reformatted; +} + +exports.ffprobe = function ffprobe(filename, cb) { + var childErr; + var args = ["-show_streams", "-show_format", filename]; + if (USE_JSON) args = ["-of", "json"].concat(args); + var child = spawn(Config.get("ffmpeg.ffprobe-exec"), args); + var stdout = ""; + var stderr = ""; + + child.on("error", function (err) { + childErr = err; + }); + + child.stdout.on("data", function (data) { + stdout += data; + }); + + child.stderr.on("data", function (data) { + stderr += data; + }); + + child.on("close", function (code) { + if (code !== 0) { + if (stderr.match(/unrecognized option|json/i) && USE_JSON) { + Logger.errlog.log("Warning: ffprobe does not support -of json. " + + "Assuming it will have old output format."); + USE_JSON = false; + return ffprobe(filename, cb); + } + + if (!childErr) childErr = new Error(stderr); + return cb(childErr); + } + + var result; + if (USE_JSON) { + try { + result = JSON.parse(stdout); + } catch (e) { + return cb(new Error("Unable to parse ffprobe output: " + e.message)); + } + } else { + try { + result = readOldFormat(stdout); + } catch (e) { + return cb(new Error("Unable to parse ffprobe output: " + e.message)); + } + } + + return cb(null, result); + }); +} + exports.query = function (filename, cb) { if (!Config.get("ffmpeg.enabled")) { return cb("Raw file playback is not enabled on this server"); @@ -29,135 +198,73 @@ exports.query = function (filename, cb) { "or HTTPS"); } - ffprobe(filename, function (err, meta) { + testUrl(filename, function (err) { if (err) { - if (meta.stderr && meta.stderr.match(/Protocol not found/)) { - return cb("Link uses a protocol unsupported by this server's ffmpeg"); - } else { + return cb(err); + } + + exports.ffprobe(filename, function (err, data) { + if (err) { + if (err.code && err.code === "ENOENT") { + return cb("Failed to execute `ffprobe`. Set ffmpeg.ffprobe-exec " + + "to the correct name of the executable in config.yaml. " + + "If you are using Debian or Ubuntu, it is probably " + + "avprobe."); + } else if (err.message) { + if (err.message.match(/protocol not found/i)) + return cb("Link uses a protocol unsupported by this server's " + + "version of ffmpeg"); + + // Ignore ffprobe error messages, they are common and most often + // indicate a problem with the remote file, not with this code. + if (!/(av|ff)probe/.test(String(err))) + Logger.errlog.log(err.stack || err); + return cb("Unable to query file data with ffmpeg"); + } else { + if (!/(av|ff)probe/.test(String(err))) + Logger.errlog.log(err.stack || err); + return cb("Unable to query file data with ffmpeg"); + } + } + + try { + data = reformatData(data); + } catch (e) { + Logger.errlog.log(e.stack || e); return cb("Unable to query file data with ffmpeg"); } - } - meta = parse(meta); - if (meta == null) { - return cb("Unknown error"); - } + if (data.medium === "video") { + if (!acceptedCodecs.hasOwnProperty(data.type)) { + return cb("Unsupported video codec " + data.type); + } - if (isVideo(meta)) { - var codec = meta.container + "/" + meta.vcodec; + data = { + title: data.title || "Raw Video", + duration: data.duration, + bitrate: data.bitrate, + codec: data.type + }; - if (!(codec in acceptedCodecs)) { - return cb("Unsupported video codec " + codec); + cb(null, data); + } else if (data.medium === "audio") { + if (!acceptedAudioCodecs.hasOwnProperty(data.acodec)) { + return cb("Unsupported audio codec " + data.acodec); + } + + data = { + title: data.title || "Raw Audio", + duration: data.duration, + bitrate: data.bitrate, + codec: data.acodec + }; + + cb(null, data); + } else { + return cb("Parsed metadata did not contain a valid video or audio " + + "stream. Either the file is invalid or it has a format " + + "unsupported by this server's version of ffmpeg."); } - - var data = { - title: meta.title || "Raw Video", - duration: Math.ceil(meta.seconds) || "--:--", - bitrate: meta.bitrate, - codec: codec - }; - - cb(null, data); - } else if (isAudio(meta)) { - var codec = meta.acodec; - - if (!(codec in acceptedAudioCodecs)) { - return cb("Unsupported audio codec " + codec); - } - - var data = { - title: meta.title || "Raw Audio", - duration: Math.ceil(meta.seconds) || "--:--", - bitrate: meta.bitrate, - codec: codec - }; - - cb(null, data); - } else if (data.ffmpegErr.match(/Protocol not found/)) { - return cb("This server is unable to load videos over the " + - filename.split(":")[0] + " protocol."); - } else { - return cb("Parsed metadata did not contain a valid video or audio stream. " + - "Either the file is invalid or it has a format unsupported by " + - "this server's version of ffmpeg."); - } + }); }); }; - -function isVideo(meta) { - return meta.vcodec && !(meta.container in audioOnlyContainers); -} - -function isAudio(meta) { - return meta.acodec; -} - -function parse(meta) { - if (meta == null) { - return null; - } - - if (!meta.format) { - return null; - } - - var data = {}; - meta.streams.forEach(function (s) { - if (s.codec_type === "video") { - data.vcodec = s.codec_name; - } else if (s.codec_type === "audio") { - data.acodec = s.codec_name; - } - }); - - data.container = meta.format.format_name.split(",")[0]; - data.bitrate = parseInt(meta.format.bit_rate) / 1000; - if (meta.format.tags) { - data.title = meta.format.tags.title; - } - data.seconds = Math.ceil(parseFloat(meta.format.duration)); - return data; -} - -function ffprobe(filename, cb) { - var ff = spawn("ffprobe", ["-show_streams", "-show_format", filename]); - - var outbuf = ""; - var errbuf = ""; - ff.stdout.on("data", function (data) { - outbuf += data; - }); - ff.stderr.on("data", function (data) { - errbuf += data; - }); - - ff.on("close", function (code) { - if (code !== 0) { - return cb("ffprobe exited with nonzero exit code", { stderr: errbuf }); - } - - var lines = outbuf.split("\n"); - var streams = []; - var format = {}; - var data = {}; - lines.forEach(function (line) { - if (line.match(/\[stream\]|\[format\]/i)) { - return; - } else if (line.match(/\[\/stream\]/i)) { - streams.push(data); - data = {}; - } else if (line.match(/\[\/format\]/i)) { - format = data; - data = {}; - } else { - var kv = line.split("="); - data[kv[0]] = kv[1]; - } - }); - - cb(null, { - streams: streams, - format: format - }); - }); -} diff --git a/lib/get-info.js b/lib/get-info.js index 375949a4..88955243 100644 --- a/lib/get-info.js +++ b/lib/get-info.js @@ -77,7 +77,8 @@ var Getters = { /* youtube.com */ yt: function (id, callback) { if (!Config.get("youtube-v3-key")) { - return Getters.yt2(id, callback); + return callback("The YouTube API now requires an API key. Please see the " + + "documentation for youtube-v3-key in config.template.yaml"); } @@ -97,7 +98,8 @@ var Getters = { /* youtube.com playlists */ yp: function (id, callback) { if (!Config.get("youtube-v3-key")) { - return Getters.yp2(id, callback); + return callback("The YouTube API now requires an API key. Please see the " + + "documentation for youtube-v3-key in config.template.yaml"); } YouTube.lookupPlaylist(id).then(function (videos) { @@ -119,7 +121,8 @@ var Getters = { /* youtube.com search */ ytSearch: function (query, callback) { if (!Config.get("youtube-v3-key")) { - return Getters.ytSearch2(query.split(" "), callback); + return callback("The YouTube API now requires an API key. Please see the " + + "documentation for youtube-v3-key in config.template.yaml"); } YouTube.search(query).then(function (res) { @@ -582,254 +585,6 @@ var Getters = { var media = new Media(id, title, "--:--", "hb"); callback(false, media); }, - - /* youtube.com - old v2 API */ - yt2: function (id, callback) { - var sv = Server.getServer(); - - var m = id.match(/([\w-]{11})/); - if (m) { - id = m[1]; - } else { - callback("Invalid ID", null); - return; - } - - var options = { - host: "gdata.youtube.com", - port: 443, - path: "/feeds/api/videos/" + id + "?v=2&alt=json", - method: "GET", - dataType: "jsonp", - timeout: 1000 - }; - - if (Config.get("youtube-v2-key")) { - options.headers = { - "X-Gdata-Key": "key=" + Config.get("youtube-v2-key") - }; - } - - urlRetrieve(https, options, function (status, data) { - switch (status) { - case 200: - break; /* Request is OK, skip to handling data */ - case 400: - return callback("Invalid request", null); - case 403: - return callback("Private video", null); - case 404: - return callback("Video not found", null); - case 500: - case 503: - return callback("Service unavailable", null); - default: - return callback("HTTP " + status, null); - } - - var buffer = data; - try { - data = JSON.parse(data); - /* Check for embedding restrictions */ - if (data.entry.yt$accessControl) { - var ac = data.entry.yt$accessControl; - for (var i = 0; i < ac.length; i++) { - if (ac[i].action === "embed") { - if (ac[i].permission === "denied") { - callback("Embedding disabled", null); - return; - } - break; - } - } - } - - var seconds = data.entry.media$group.yt$duration.seconds; - var title = data.entry.title.$t; - var meta = {}; - /* Check for country restrictions */ - if (data.entry.media$group.media$restriction) { - var rest = data.entry.media$group.media$restriction; - if (rest.length > 0) { - if (rest[0].relationship === "deny") { - meta.restricted = rest[0].$t; - } - } - } - var media = new Media(id, title, seconds, "yt", meta); - callback(false, media); - } catch (e) { - // Gdata version 2 has the rather silly habit of - // returning error codes in XML when I explicitly asked - // for JSON - var m = buffer.match(/([^<]+)<\/internalReason>/); - if (m === null) - m = buffer.match(/([^<]+)<\/code>/); - - var err = e; - if (m) { - if(m[1] === "too_many_recent_calls") { - err = "YouTube is throttling the server right "+ - "now for making too many requests. "+ - "Please try again in a moment."; - } else { - err = m[1]; - } - } - - callback(err, null); - } - }); - }, - - /* youtube.com playlists - old v2 api */ - yp2: function (id, callback, url) { - /** - * NOTE: callback may be called multiple times, once for each <= 25 video - * batch of videos in the list. It will be called in order. - */ - var m = id.match(/([\w-]+)/); - if (m) { - id = m[1]; - } else { - callback("Invalid ID", null); - return; - } - var path = "/feeds/api/playlists/" + id + "?v=2&alt=json"; - /** - * NOTE: the third parameter, url, is used to chain this retriever - * multiple times to get all the videos from a playlist, as each - * request only returns 25 videos. - */ - if (url !== undefined) { - path = "/" + url.split("gdata.youtube.com")[1]; - } - - var options = { - host: "gdata.youtube.com", - port: 443, - path: path, - method: "GET", - dataType: "jsonp", - timeout: 1000 - }; - - if (Config.get("youtube-v2-key")) { - options.headers = { - "X-Gdata-Key": "key=" + Config.get("youtube-v2-key") - }; - } - - urlRetrieve(https, options, function (status, data) { - switch (status) { - case 200: - break; /* Request is OK, skip to handling data */ - case 400: - return callback("Invalid request", null); - case 403: - return callback("Private playlist", null); - case 404: - return callback("Playlist not found", null); - case 500: - case 503: - return callback("Service unavailable", null); - default: - return callback("HTTP " + status, null); - } - - try { - data = JSON.parse(data); - var vids = []; - for(var i in data.feed.entry) { - try { - /** - * FIXME: This should probably check for embed restrictions - * and country restrictions on each video in the list - */ - var item = data.feed.entry[i]; - var id = item.media$group.yt$videoid.$t; - var title = item.title.$t; - var seconds = item.media$group.yt$duration.seconds; - var media = new Media(id, title, seconds, "yt"); - vids.push(media); - } catch(e) { - } - } - - callback(false, vids); - - var links = data.feed.link; - for (var i in links) { - if (links[i].rel === "next") { - /* Look up the next batch of videos from the list */ - Getters["yp2"](id, callback, links[i].href); - } - } - } catch (e) { - callback(e, null); - } - - }); - }, - - /* youtube.com search - old v2 api */ - ytSearch2: function (terms, callback) { - /** - * terms is a list of words from the search query. Each word must be - * encoded properly for use in the request URI - */ - for (var i in terms) { - terms[i] = encodeURIComponent(terms[i]); - } - var query = terms.join("+"); - - var options = { - host: "gdata.youtube.com", - port: 443, - path: "/feeds/api/videos/?q=" + query + "&v=2&alt=json", - method: "GET", - dataType: "jsonp", - timeout: 1000 - }; - - if (Config.get("youtube-v2-key")) { - options.headers = { - "X-Gdata-Key": "key=" + Config.get("youtube-v2-key") - }; - } - - urlRetrieve(https, options, function (status, data) { - if (status !== 200) { - callback("YouTube search: HTTP " + status, null); - return; - } - - try { - data = JSON.parse(data); - var vids = []; - for(var i in data.feed.entry) { - try { - /** - * FIXME: This should probably check for embed restrictions - * and country restrictions on each video in the list - */ - var item = data.feed.entry[i]; - var id = item.media$group.yt$videoid.$t; - var title = item.title.$t; - var seconds = item.media$group.yt$duration.seconds; - var media = new Media(id, title, seconds, "yt"); - media.thumb = item.media$group.media$thumbnail[0]; - vids.push(media); - } catch(e) { - } - } - - callback(false, vids); - } catch(e) { - callback(e, null); - } - }); - }, }; module.exports = { diff --git a/lib/status-messages.js b/lib/status-messages.js new file mode 100644 index 00000000..ae6a0142 --- /dev/null +++ b/lib/status-messages.js @@ -0,0 +1,83 @@ +// This status message map is taken from the node.js source code. The original +// copyright notice for lib/_http_server.js is reproduced below. +// +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +module.exports = { + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Moved Temporarily", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Time-out", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Large", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Unordered Collection", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Time-out", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 509: "Bandwidth Limit Exceeded", + 510: "Not Extended", + 511: "Network Authentication Required" +}; diff --git a/package.json b/package.json index 8c2f5a0d..523f7db6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "cookie-parser": "^1.3.3", "csrf": "^2.0.6", "cytube-mediaquery": "git://github.com/CyTube/mediaquery", - "cytubefilters": "git://github.com/calzoneman/cytubefilters#7663b3a9", + "cytubefilters": "git://github.com/calzoneman/cytubefilters#65e685ad", "express": "^4.11.1", "express-minify": "^0.1.3", "graceful-fs": "^3.0.5", diff --git a/templates/channel.jade b/templates/channel.jade index f95799a1..128d7933 100644 --- a/templates/channel.jade +++ b/templates/channel.jade @@ -21,7 +21,7 @@ html(lang="en") b.caret ul.dropdown-menu li: a(href="#" onclick="javascript:chatOnly()") Chat Only - li: a(href="#" onclick="javascript:removeVideo()") Remove Video + li: a(href="#" onclick="javascript:removeVideo(event)") Remove Video mixin navloginlogout(cname) section#mainpage .container @@ -56,6 +56,7 @@ html(lang="en") #controlsrow.row #leftcontrols.col-lg-5.col-md-5 button#newpollbtn.btn.btn-sm.btn-default New Poll + button#emotelistbtn.btn.btn-sm.btn-default Emote List #rightcontrols.col-lg-7.col-md-7 #plcontrol.btn-group.pull-left button#showsearch.btn.btn-sm.btn-default(title="Search for a video", data-toggle="collapse", data-target="#searchcontrol") @@ -125,7 +126,7 @@ html(lang="en") input.add-temp(type="checkbox") | Add as temporary | Paste the embed code below and click Next or At End. - | Acceptable embed codes are <iframe> and <object> tags. + | Acceptable embed codes are <iframe> and <object> tags. CUSTOM EMBEDS CANNOT BE SYNCHRONIZED. textarea#customembed-content.input-block-level.form-control(rows="3") #playlistmanager.collapse.plcontrol-collapse.col-lg-12.col-md-12 .vertical-spacer @@ -173,6 +174,24 @@ html(lang="en") .modal-footer button.btn.btn-primary(type="button", data-dismiss="modal", onclick="javascript:saveUserOptions()") Save button.btn.btn-default(type="button", data-dismiss="modal") Close + #emotelist.modal.fade(tabindex="-1", role="dialog", aria-hidden="true") + .modal-dialog.modal-dialog-nonfluid + .modal-content + .modal-header + button.close(data-dismiss="modal", aria-hidden="true") × + h4 Emote List + .modal-body + .pull-left + input#emotelist-search.form-control(type="text", placeholder="Search") + .pull-right + .checkbox + label + input#emotelist-alphabetical(type="checkbox") + | Sort alphabetically + #emotelist-paginator-container + table + tbody + .modal-footer #channeloptions.modal.fade(tabindex="-1", role="dialog", aria-hidden="true") .modal-dialog .modal-content diff --git a/www/css/cytube.css b/www/css/cytube.css index 8441008e..12f53b2a 100644 --- a/www/css/cytube.css +++ b/www/css/cytube.css @@ -557,15 +557,15 @@ body.chatOnly .pm-panel, body.chatOnly .pm-panel-placeholder { } @media screen and (min-width: 768px) { - .modal { - padding: 30px; - } - .modal-dialog { min-width: 600px!important; max-width: 1200px!important; width: auto!important; } + + .modal-dialog-nonfluid.modal-dialog { + max-width: 600px!important; + } } table td { @@ -592,3 +592,36 @@ table td { border: 1px solid; border-top-width: 0; } + +#emotelist table { + margin: auto; +} + +.emote-preview-container { + width: 100px; + height: 100px; + float: left; + text-align: center; + white-space: nowrap; + margin: 5px; +} + +.emote-preview-hax { + display: inline-block; + vertical-align: middle; + height: 100%; +} + +.emote-preview { + max-width: 100px; + max-height: 100px; + cursor: pointer; +} + +#emotelist-paginator-container { + text-align: center; +} + +#leftcontrols .btn { + margin-right: 5px; +} diff --git a/www/css/themes/bootstrap-theme.min.css b/www/css/themes/bootstrap-theme.min.css index 54daf5ae..29fbaabd 100644 --- a/www/css/themes/bootstrap-theme.min.css +++ b/www/css/themes/bootstrap-theme.min.css @@ -32,6 +32,10 @@ footer { background-color: #ffffff; } +#emotelist td { + background-color: #f0f0f0; +} + .chat-shadow { color: #aaaaaa; } diff --git a/www/css/themes/cyborg.css b/www/css/themes/cyborg.css index 14a6050f..cde33171 100644 --- a/www/css/themes/cyborg.css +++ b/www/css/themes/cyborg.css @@ -39,7 +39,7 @@ input.form-control[type="email"], textarea.form-control { color: #c8c8c8; } -.profile-box, .user-dropdown { +.profile-box, .user-dropdown, #emotelist td { color: #c8c8c8; background-color: #2d2d2d; } diff --git a/www/css/themes/light.css b/www/css/themes/light.css index 5fc33777..d097a62b 100644 --- a/www/css/themes/light.css +++ b/www/css/themes/light.css @@ -26,6 +26,10 @@ footer { background-color: #ffffff; } +#emotelist td { + background-color: #f0f0f0; +} + .chat-shadow { color: #aaaaaa; } diff --git a/www/css/themes/modern.css b/www/css/themes/modern.css index 6673f22c..035cef9d 100644 --- a/www/css/themes/modern.css +++ b/www/css/themes/modern.css @@ -46,6 +46,15 @@ input.form-control[type="email"], textarea.form-control { border-radius: 0px !important; } +#emotelist table { + background-color: #2a2d30; +} + +#emotelist td { + background-color: rgba(28, 30, 34, 0.95); + border: none; +} + .profile-image { border-radius: 0px; border: solid 1px #000000 !important; @@ -176,4 +185,4 @@ input.form-control[type="email"], textarea.form-control { margin-bottom: 9px; min-height: 20px; padding: 10px 19px !important; -} \ No newline at end of file +} diff --git a/www/css/themes/slate.css b/www/css/themes/slate.css index 0c46cda1..2e309158 100644 --- a/www/css/themes/slate.css +++ b/www/css/themes/slate.css @@ -51,7 +51,7 @@ input.form-control[type="email"], textarea.form-control { background-image: linear-gradient(#484e55, #3a3f44 60%, #313539) !important; } -.profile-box, .user-dropdown { +.profile-box, .user-dropdown, #emotelist td { color: #c8c8c8; background-color: #161a20; } diff --git a/www/js/callbacks.js b/www/js/callbacks.js index 57efb3bd..bac504ab 100644 --- a/www/js/callbacks.js +++ b/www/js/callbacks.js @@ -124,6 +124,7 @@ Callbacks = { cooldown: function (time) { time = time + 200; $("#chatline").css("color", "#ff0000"); + $(".pm-input").css("color", "#ff0000"); if (CHATTHROTTLE && $("#chatline").data("throttle_timer")) { clearTimeout($("#chatline").data("throttle_timer")); } @@ -131,6 +132,7 @@ Callbacks = { $("#chatline").data("throttle_timer", setTimeout(function () { CHATTHROTTLE = false; $("#chatline").css("color", ""); + $(".pm-input").css("color", ""); }, time)); }, @@ -279,7 +281,7 @@ Callbacks = { channelCSSJS: function(data) { $("#chancss").remove(); CHANNEL.css = data.css; - $("#csstext").val(data.css); + $("#cs-csstext").val(data.css); if(data.css && !USEROPTS.ignore_channelcss) { $("