Merge branch 'LQDJudge:master' into master

This commit is contained in:
Bao Le 2023-08-25 09:22:15 +07:00 committed by GitHub
commit 84aabb9dd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 932 additions and 532 deletions

View file

@ -84,6 +84,7 @@ DMOJ_STATS_SUBMISSION_RESULT_COLORS = {
"CE": "#42586d",
"ERR": "#ffa71c",
}
DMOJ_PROFILE_IMAGE_ROOT = "profile_images"
MARKDOWN_STYLES = {}
MARKDOWN_DEFAULT_STYLE = {}

View file

@ -1,5 +1,6 @@
from django.contrib import admin
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import User
from judge.admin.comments import CommentAdmin
from judge.admin.contest import ContestAdmin, ContestParticipationAdmin, ContestTagAdmin
@ -11,7 +12,7 @@ from judge.admin.interface import (
)
from judge.admin.organization import OrganizationAdmin, OrganizationRequestAdmin
from judge.admin.problem import ProblemAdmin, ProblemPointsVoteAdmin
from judge.admin.profile import ProfileAdmin
from judge.admin.profile import ProfileAdmin, UserAdmin
from judge.admin.runtime import JudgeAdmin, LanguageAdmin
from judge.admin.submission import SubmissionAdmin
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
@ -66,3 +67,5 @@ admin.site.register(Submission, SubmissionAdmin)
admin.site.register(Ticket, TicketAdmin)
admin.site.register(VolunteerProblemVote, VolunteerProblemVoteAdmin)
admin.site.register(Course)
admin.site.unregister(User)
admin.site.register(User, UserAdmin)

View file

@ -3,6 +3,7 @@ from django.forms import ModelForm
from django.utils.html import format_html
from django.utils.translation import gettext, gettext_lazy as _, ungettext
from reversion.admin import VersionAdmin
from django.contrib.auth.admin import UserAdmin as OldUserAdmin
from django_ace import AceWidget
from judge.models import Profile
@ -167,3 +168,38 @@ class ProfileAdmin(VersionAdmin):
"javascript", request.profile.ace_theme
)
return form
class UserAdmin(OldUserAdmin):
# Customize the fieldsets for adding and editing users
fieldsets = (
(None, {"fields": ("username", "password")}),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
(
"Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
readonly_fields = ("last_login", "date_joined")
def get_readonly_fields(self, request, obj=None):
fields = self.readonly_fields
if not request.user.is_superuser:
fields += (
"is_staff",
"is_active",
"is_superuser",
"groups",
"user_permissions",
)
return fields

View file

@ -16,7 +16,7 @@ class EventPoster(object):
def _connect(self):
self._conn = pika.BlockingConnection(
pika.URLParameters(settings.EVENT_DAEMON_AMQP)
pika.URLParameters(settings.EVENT_DAEMON_AMQP),
)
self._chan = self._conn.channel()
@ -25,7 +25,7 @@ class EventPoster(object):
id = int(time() * 1000000)
self._chan.basic_publish(
self._exchange,
"",
"#",
json.dumps({"id": id, "channel": channel, "message": message}),
)
return id

View file

@ -50,6 +50,7 @@ from judge.widgets import (
HeavySelect2Widget,
Select2MultipleWidget,
DateTimePickerWidget,
ImageWidget,
)
from judge.tasks import rescore_contest
@ -78,12 +79,14 @@ class ProfileForm(ModelForm):
"language",
"ace_theme",
"user_script",
"profile_image",
]
widgets = {
"user_script": AceWidget(theme="github"),
"timezone": Select2Widget(attrs={"style": "width:200px"}),
"language": Select2Widget(attrs={"style": "width:200px"}),
"ace_theme": Select2Widget(attrs={"style": "width:200px"}),
"profile_image": ImageWidget,
}
has_math_config = bool(settings.MATHOID_URL)
@ -100,12 +103,22 @@ class ProfileForm(ModelForm):
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
super(ProfileForm, self).__init__(*args, **kwargs)
self.fields["profile_image"].required = False
def clean_profile_image(self):
profile_image = self.cleaned_data.get("profile_image")
if profile_image:
if profile_image.size > 5 * 1024 * 1024:
raise ValidationError(
_("File size exceeds the maximum allowed limit of 5MB.")
)
return profile_image
def file_size_validator(file):
limit = 1 * 1024 * 1024
limit = 10 * 1024 * 1024
if file.size > limit:
raise ValidationError("File too large. Size should not exceed 1MB.")
raise ValidationError("File too large. Size should not exceed 10MB.")
class ProblemSubmitForm(ModelForm):
@ -474,6 +487,15 @@ class ContestCloneForm(Form):
max_length=20,
validators=[RegexValidator("^[a-z0-9]+$", _("Contest id must be ^[a-z0-9]+$"))],
)
organization = ChoiceField(choices=(), required=True)
def __init__(self, *args, org_choices=(), profile=None, **kwargs):
super(ContestCloneForm, self).__init__(*args, **kwargs)
self.fields["organization"].widget = Select2Widget(
attrs={"style": "width: 100%", "data-placeholder": _("Group")},
)
self.fields["organization"].choices = org_choices
self.profile = profile
def clean_key(self):
key = self.cleaned_data["key"]
@ -481,6 +503,16 @@ class ContestCloneForm(Form):
raise ValidationError(_("Contest with key already exists."))
return key
def clean_organization(self):
organization_id = self.cleaned_data["organization"]
try:
organization = Organization.objects.get(id=organization_id)
except Exception:
raise ValidationError(_("Group doesn't exist."))
if not organization.admins.filter(id=self.profile.id).exists():
raise ValidationError(_("You don't have permission in this group."))
return organization
class ProblemPointsVoteForm(ModelForm):
class Meta:

View file

@ -9,14 +9,15 @@ from . import registry
@registry.function
def gravatar(email, size=80, default=None):
if isinstance(email, Profile):
def gravatar(profile, size=80, default=None, profile_image=None, email=None):
if profile_image:
return profile_image
if profile and profile.profile_image:
return profile.profile_image.url
if profile:
email = email or profile.user.email
if default is None:
default = email.mute
email = email.user.email
elif isinstance(email, AbstractUser):
email = email.email
default = profile.mute
gravatar_url = (
"//www.gravatar.com/avatar/"
+ hashlib.md5(utf8bytes(email.strip().lower())).hexdigest()

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.18 on 2023-08-24 00:50
from django.db import migrations, models
import judge.models.profile
class Migration(migrations.Migration):
dependencies = [
("judge", "0161_auto_20230803_1536"),
]
operations = [
migrations.AddField(
model_name="profile",
name="profile_image",
field=models.ImageField(
null=True, upload_to=judge.models.profile.profile_image_path
),
),
]

View file

@ -1,4 +1,5 @@
from operator import mul
import os
from django.conf import settings
from django.contrib.auth.models import User
@ -27,6 +28,12 @@ class EncryptedNullCharField(EncryptedCharField):
return super(EncryptedNullCharField, self).get_prep_value(value)
def profile_image_path(profile, filename):
tail = filename.split(".")[-1]
new_filename = f"user_{profile.id}.{tail}"
return os.path.join(settings.DMOJ_PROFILE_IMAGE_ROOT, new_filename)
class Organization(models.Model):
name = models.CharField(max_length=128, verbose_name=_("organization title"))
slug = models.SlugField(
@ -229,6 +236,7 @@ class Profile(models.Model):
blank=True,
help_text=_("Notes for administrators regarding this user."),
)
profile_image = models.ImageField(upload_to=profile_image_path, null=True)
@cached_property
def organization(self):

View file

@ -453,9 +453,19 @@ class ContestClone(
form_class = ContestCloneForm
permission_required = "judge.clone_contest"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["org_choices"] = tuple(
Organization.objects.filter(admins=self.request.profile).values_list(
"id", "name"
)
)
kwargs["profile"] = self.request.profile
return kwargs
def form_valid(self, form):
tags = self.object.tags.all()
organizations = self.object.organizations.all()
organization = form.cleaned_data["organization"]
private_contestants = self.object.private_contestants.all()
view_contest_scoreboard = self.object.view_contest_scoreboard.all()
contest_problems = self.object.contest_problems.all()
@ -469,7 +479,7 @@ class ContestClone(
contest.save()
contest.tags.set(tags)
contest.organizations.set(organizations)
contest.organizations.set([organization])
contest.private_contestants.set(private_contestants)
contest.view_contest_scoreboard.set(view_contest_scoreboard)
contest.authors.add(self.request.profile)
@ -480,7 +490,14 @@ class ContestClone(
ContestProblem.objects.bulk_create(contest_problems)
return HttpResponseRedirect(
reverse("admin:judge_contest_change", args=(contest.id,))
reverse(
"organization_contest_edit",
args=(
organization.id,
organization.slug,
contest.key,
),
)
)

View file

@ -1,8 +1,11 @@
from urllib.parse import urljoin
from django.db.models import F, Q
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.encoding import smart_text
from django.views.generic.list import BaseListView
from django.conf import settings
from chat_box.utils import encrypt_url
@ -54,7 +57,6 @@ class Select2View(BaseListView):
class UserSelect2View(Select2View):
def get(self, request, *args, **kwargs):
self.org_id = kwargs.get("org_id", request.GET.get("org_id", ""))
print(self.org_id)
return super(UserSelect2View, self).get(request, *args, **kwargs)
def get_queryset(self):
@ -100,6 +102,21 @@ class UserSearchSelect2View(BaseListView):
def get_queryset(self):
return _get_user_queryset(self.term)
def get_json_result_from_object(self, user_tuple):
pk, username, email, display_rank, profile_image = user_tuple
return {
"text": username,
"id": username,
"gravatar_url": gravatar(
None,
self.gravatar_size,
self.gravatar_default,
self.get_profile_image_url(profile_image),
email,
),
"display_rank": display_rank,
}
def get(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
@ -108,7 +125,7 @@ class UserSearchSelect2View(BaseListView):
self.gravatar_default = request.GET.get("gravatar_default", None)
self.object_list = self.get_queryset().values_list(
"pk", "user__username", "user__email", "display_rank"
"pk", "user__username", "user__email", "display_rank", "profile_image"
)
context = self.get_context_data()
@ -116,15 +133,8 @@ class UserSearchSelect2View(BaseListView):
return JsonResponse(
{
"results": [
{
"text": username,
"id": username,
"gravatar_url": gravatar(
email, self.gravatar_size, self.gravatar_default
),
"display_rank": display_rank,
}
for pk, username, email, display_rank in context["object_list"]
self.get_json_result_from_object(user_tuple)
for user_tuple in context["object_list"]
],
"more": context["page_obj"].has_next(),
}
@ -133,6 +143,11 @@ class UserSearchSelect2View(BaseListView):
def get_name(self, obj):
return str(obj)
def get_profile_image_url(self, profile_image):
if profile_image:
return urljoin(settings.MEDIA_URL, profile_image)
return None
class ContestUserSearchSelect2View(UserSearchSelect2View):
def get_queryset(self):
@ -161,43 +176,20 @@ class AssigneeSelect2View(UserSearchSelect2View):
).distinct()
class ChatUserSearchSelect2View(BaseListView):
paginate_by = 20
def get_queryset(self): # TODO: add block
return _get_user_queryset(self.term)
def get(self, request, *args, **kwargs):
class ChatUserSearchSelect2View(UserSearchSelect2View):
def get_json_result_from_object(self, user_tuple):
if not self.request.user.is_authenticated:
raise Http404()
self.request = request
self.kwargs = kwargs
self.term = kwargs.get("term", request.GET.get("term", ""))
self.gravatar_size = request.GET.get("gravatar_size", 128)
self.gravatar_default = request.GET.get("gravatar_default", None)
self.object_list = self.get_queryset().values_list(
"pk", "user__username", "user__email", "display_rank"
)
context = self.get_context_data()
return JsonResponse(
{
"results": [
{
"text": username,
"id": encrypt_url(request.profile.id, pk),
"gravatar_url": gravatar(
email, self.gravatar_size, self.gravatar_default
),
"display_rank": display_rank,
}
for pk, username, email, display_rank in context["object_list"]
],
"more": context["page_obj"].has_next(),
}
)
def get_name(self, obj):
return str(obj)
pk, username, email, display_rank, profile_image = user_tuple
return {
"text": username,
"id": encrypt_url(self.request.profile.id, pk),
"gravatar_url": gravatar(
None,
self.gravatar_size,
self.gravatar_default,
self.get_profile_image_url(profile_image),
email,
),
"display_rank": display_rank,
}

View file

@ -402,12 +402,12 @@ class UserPerformancePointsAjax(UserProblemsPage):
@login_required
def edit_profile(request):
profile = Profile.objects.get(user=request.user)
if profile.mute:
raise Http404()
profile = request.profile
if request.method == "POST":
form_user = UserForm(request.POST, instance=request.user)
form = ProfileForm(request.POST, instance=profile, user=request.user)
form = ProfileForm(
request.POST, request.FILES, instance=profile, user=request.user
)
if form_user.is_valid() and form.is_valid():
with transaction.atomic(), revisions.create_revision():
form_user.save()

View file

@ -3,3 +3,4 @@ from judge.widgets.mixins import CompressorWidgetMixin
from judge.widgets.pagedown import *
from judge.widgets.select2 import *
from judge.widgets.datetime import *
from judge.widgets.image import *

16
judge/widgets/image.py Normal file
View file

@ -0,0 +1,16 @@
from django import forms
class ImageWidget(forms.ClearableFileInput):
template_name = "widgets/image.html"
def __init__(self, attrs=None, width=80, height=80):
self.width = width
self.height = height
super().__init__(attrs)
def get_context(self, name, value, attrs=None):
context = super().get_context(name, value, attrs)
context["widget"]["height"] = self.height
context["widget"]["width"] = self.height
return context

File diff suppressed because it is too large Load diff

View file

@ -313,6 +313,11 @@
padding: 0.8em 0.2em 0.8em 1em;
}
.sidebar-text {
overflow: hidden;
text-overflow: ellipsis;
}
.middle-content,
.blog-sidebar,
.right-sidebar {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before After
Before After

View file

@ -263,7 +263,7 @@
<span id="user-links">
<ul><li><a href="javascript:void(0)">
<span>
<img src="{{ gravatar(request.user, 32) }}" height="24" width="24">{# -#}
<img src="{{ gravatar(request.profile, 32) }}" height="24" width="24">{# -#}
<span>
<b class="{{request.profile.css_class}}">{{ request.user.username }}</b>
</span>
@ -271,24 +271,38 @@
</a></li></ul>
</span>
<div class="dropdown" id="userlink_dropdown" role="tooptip">
<div class="dropdown-item"><a href="{{ url('user_page') }}">{{ _('Profile') }}</a></div>
<a href="{{ url('user_page') }}">
<div class="dropdown-item">{{ _('Profile') }}</div>
</a>
{% if request.user.is_staff or request.user.is_superuser %}
<div class="dropdown-item"><a href="{{ url('admin:index') }}">{{ _('Admin') }}</a></div>
<a href="{{ url('admin:index') }}">
<div class="dropdown-item">{{ _('Admin') }}</div>
</a>
{% endif %}
{% if request.user.is_superuser %}
<div class="dropdown-item"><a href="{{ url('internal_problem') }}">{{ _('Internal') }}</a></div>
<div class="dropdown-item"><a href="{{ url('site_stats') }}">{{ _('Stats') }}</a></div>
<a href="{{ url('internal_problem') }}">
<div class="dropdown-item">{{ _('Internal') }}</div>
</a>
<a href="{{ url('site_stats') }}">
<div class="dropdown-item">{{ _('Stats') }}</div>
</a>
{% endif %}
<div class="dropdown-item"><a href="{{ url('user_edit_profile') }}">{{ _('Edit profile') }}</a></div>
<a href="{{ url('user_edit_profile') }}">
<div class="dropdown-item">{{ _('Edit profile') }}</div>
</a>
{% if request.user.is_impersonate %}
<div class="dropdown-item"><a href="{{ url('impersonate-stop') }}">Stop impersonating</a></div>
<a href="{{ url('impersonate-stop') }}">
<div class="dropdown-item">Stop impersonating</div>
</a>
{% else %}
<div class="dropdown-item">
<a href="#" id="logout" class="red">{{ _('Log out') }}</a>
<form id="logout-form" action="{{ url('auth_logout') }}" method="POST">
{% csrf_token %}
</form>
</div>
<a href="#" id="logout" class="red">
<div class="dropdown-item">
{{ _('Log out') }}
<form id="logout-form" action="{{ url('auth_logout') }}" method="POST">
{% csrf_token %}
</form>
</div>
</a>
{% endif %}
</div>
{% else %}

View file

@ -3,7 +3,7 @@
</h3>
<div class="sidebox-content">
<div class="user-gravatar">
<img src="{{ gravatar(request.user, 135) }}"
<img src="{{ gravatar(request.profile, 135) }}"
alt="gravatar" width="135px" height="135px">
</div>
<div class="recently-attempted">

View file

@ -42,8 +42,8 @@
{% block left_sidebar %}
<div class="left-sidebar">
{{ make_tab_item('blog', 'fa fa-rss', url('home'), _('News')) }}
{{ make_tab_item('comment', 'fa fa-comments', url('comment_feed'), _('Comments')) }}
{{ make_tab_item('ticket', 'fa fa-question-circle', url('ticket_feed'), _('Tickets')) }}
{{ make_tab_item('comment', 'fa fa-comments', url('comment_feed'), _('Comment')) }}
{{ make_tab_item('ticket', 'fa fa-question-circle', url('ticket_feed'), _('Ticket')) }}
{{ make_tab_item('event', 'fa fa-calendar', '#', _('Events')) }}
</div>
{% endblock %}

View file

@ -499,18 +499,28 @@
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
load_dynamic_update({{last_msg}});
const button = document.querySelector('#emoji-button')
const tooltip = document.querySelector('.tooltip')
Popper.createPopper(button, tooltip)
const button = document.querySelector('#emoji-button');
const tooltip = document.querySelector('.tooltip');
Popper.createPopper(button, tooltip, {
placement: 'left-end',
});
function toggleEmoji() {
tooltip.classList.toggle('shown')
}
$('#emoji-button').on('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggleEmoji();
});
// Đóng bảng emoji khi click bất kỳ chỗ nào trên màn hình
document.addEventListener("click", function(e) {
if (!tooltip.contains(e.target)) {
tooltip.classList.remove('shown'); // Ẩn bảng emoji
}
});
$('emoji-picker').on('emoji-click', function(e) {
var $chat = $('#chat-input').get(0);
insert_char_after_cursor($chat, e.detail.unicode);
@ -629,7 +639,7 @@
</div>
<div id="chat-box">
<img src="{{static('loading.gif')}}" id="loader">
<img src="{{static('loading.gif')}}" id="loader" height="2em">
<ul id="chat-log" style="display: none">
{% include 'chat/message_list.html' %}
</ul>

View file

@ -43,9 +43,8 @@
}
.tooltip {
left: 120vh !important;
transform: translate(100px, 0) !important;
position: absolute;
z-index: 1000;
}
#loader {

View file

@ -1,6 +1,6 @@
{% if other_user %}
<div class="status-container" style="height: 100%">
<img src="{{ gravatar(other_user.user, 135) }}" class="info-pic">
<img src="{{ gravatar(other_user, 135) }}" class="info-pic">
<svg style="position:absolute; height:100%; width: 100%">
<circle class="info-circle"
fill="{{'green' if other_online else 'red'}}"/>

View file

@ -25,17 +25,32 @@
</style>
{% endblock %}
{% block js_media %}
<script type="text/javascript">
$(function() {
$("#id_organization").select2();
});
</script>
{% endblock %}
{% block body %}
<form id="contest-clone-panel" action="" method="post" class="form-area">
{% csrf_token %}
{% if form.errors %}
{% if form.key.errors %}
<div id="form-errors">
{{ form.key.errors }}
<div>{{ form.key.errors }}</div>
</div>
{% endif %}
<div><label class="inline-header grayed">{{ _('Enter a new key for the cloned contest:') }}</label></div>
<div id="contest-key-container"><span class="fullwidth">{{ form.key }}</span></div>
<div><label class="inline-header grayed">{{ _('Group:') }}</label></div>
{{form.organization}}
{% if form.organization.errors %}
<div id="form-errors">
<div>{{ form.organization.errors }}</div>
</div>
{% endif %}
<hr>
<button style="float: right;" type="submit">{{ _('Clone!') }}</button>
</form>

View file

@ -118,7 +118,7 @@
{% macro make_tab_item(name, fa, url, text) %}
<div class="left-sidebar-item {% if page_type == name %}active{% endif %}" data-href="{{ url }}" id="{{ name }}-tab">
<span class="sidebar-icon"><i class="{{ fa }}"></i></span>
<span>{{ text }}</span>
<span class="sidebar-text">{{ text }}</span>
</div>
{% endmacro %}

View file

@ -162,7 +162,7 @@
<section class="message new-message">
<div class="info">
<a href="{{ url('user_page', request.user.username) }}" class="user">
<img src="{{ gravatar(request.user, 135) }}" class="gravatar">
<img src="{{ gravatar(request.profile, 135) }}" class="gravatar">
<div class="username {{ request.profile.css_class }}">{{ request.user.username }}</div>
</a>
</div>

View file

@ -43,9 +43,9 @@
#center-float {
position: relative;
margin: 0 auto auto -28.5em;
left: 60%;
width: 700px;
width: 100%;
display: flex;
justify-content: center;
}
</style>
{% endblock %}
@ -79,11 +79,13 @@
{% block body %}
<div id="center-float">
<form id="edit-form" action="" method="post" class="form-area">
{% if form.errors %}
<form id="edit-form" action="" method="post" class="form-area" enctype="multipart/form-data">
{% if form.errors or form_user.errors %}
<div class="alert alert-danger alert-dismissable">
<a href="#" class="close">x</a>
{{ form.non_field_errors() }}
{{ form.errors }}
<br>
{{ form_user.errors }}
</div>
{% endif %}
@ -98,6 +100,10 @@
<td> {{ _('School') }}: </td>
<td> {{ form_user.last_name }} </td>
</tr>
<tr>
<td style="padding-top: 1em">{{ _('Avatar') }}: </td>
<td style="padding-top: 1em">{{ form.profile_image }}</td>
</tr>
</table>
<hr>
@ -127,12 +133,6 @@
<td><span class="fullwidth">{{ form.math_engine }}</span></td>
</tr>
{% endif %}
<tr>
<td colspan="2">
<a href="http://www.gravatar.com/" title="{{ _('Change your avatar') }}"
target="_blank" class="inline-header">{{ _('Change your avatar') }}</a>
</td>
</tr>
<tr>
<td colspan="2">
<a href="{{ url('password_change') }}" class="inline-header">

View file

@ -0,0 +1,13 @@
{% if widget.is_initial %}
<div>
<a href="{{widget.value.url}}" target=_blank>
<img src="{{widget.value.url}}" width="{{widget.width}}" height="{{widget.height}}" style="border-radius: 3px;">
</a>
<div>
{{ widget.input_text }}:
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}">
{% if widget.is_initial %}
</div>
</div>
{% endif %}

198
websocket/daemon_amqp.js Normal file
View file

@ -0,0 +1,198 @@
var WebSocketServer = require('ws').Server;
var set = require('simplesets').Set;
var queue = require('qu');
var amqp = require('amqp');
var url = require('url');
if (typeof String.prototype.startsWith != 'function') {
String.prototype.startsWith = function (str){
return this.slice(0, str.length) == str;
};
}
const argv = require('yargs')
.demandCommand(3)
.strict()
.usage('Usage: event [options] <amqp url> <exchange> <port>')
.options({
host: {
default: '127.0.0.1',
describe: 'websocket address to listen on'
},
http_host: {
default: '127.0.0.1',
describe: 'http address to listen on'
},
http_port: {
default: null,
describe: 'http port to listen on'
},
max_queue: {
default: 10,
describe: 'queue buffer size'
},
comet_timeout: {
default: 60000,
describe: 'comet long poll timeout'
}
})
.argv;
var followers = new set();
var pollers = new set();
var messages = new queue();
var max_queue = argv.max_queue;
var comet_timeout = argv.comet_timeout;
var rabbitmq = amqp.createConnection({url: argv._[0]});
rabbitmq.on('error', function(e) {
console.log('amqp connection error...', e);
process.exit(1);
});
rabbitmq.on('ready', function () {
rabbitmq.queue('', {exclusive: true}, function (q) {
q.bind(argv._[1], '#');
q.subscribe(function (data) {
message = JSON.parse(data.data.toString('utf8'));
messages.push(message);
if (messages.length > max_queue)
messages.shift();
followers.each(function (client) {
client.got_message(message);
});
pollers.each(function (request) {
request.got_message(message);
});
});
});
});
var wss = new WebSocketServer({host: argv.host, port: parseInt(argv._[2])});
messages.catch_up = function (client) {
this.each(function (message) {
if (message.id > client.last_msg)
client.got_message(message);
});
};
wss.on('connection', function (socket) {
socket.channel = null;
socket.last_msg = 0;
var commands = {
start_msg: function (request) {
socket.last_msg = request.start;
},
set_filter: function (request) {
var filter = {};
if (Array.isArray(request.filter) && request.filter.length > 0 &&
request.filter.every(function (channel, index, array) {
if (typeof channel != 'string')
return false;
filter[channel] = true;
return true;
})) {
socket.filter = filter;
followers.add(socket);
messages.catch_up(socket);
} else {
socket.send(JSON.stringify({
status: 'error',
code: 'invalid-filter',
message: 'invalid filter: ' + request.filter
}));
}
}
};
socket.got_message = function (message) {
if (message.channel in socket.filter)
socket.send(JSON.stringify(message));
socket.last_msg = message.id;
};
socket.on('message', function (request) {
try {
request = JSON.parse(request);
if (typeof request.command !== 'string')
throw {message: 'no command specified'};
} catch (err) {
socket.send(JSON.stringify({
status: 'error',
code: 'syntax-error',
message: err.message
}));
return;
}
request.command = request.command.replace(/-/g, '_');
if (request.command in commands)
commands[request.command](request);
else
socket.send(JSON.stringify({
status: 'error',
code: 'bad-command',
message: 'bad command: ' + request.command
}));
});
socket.on('close', function(code, message) {
followers.remove(socket);
});
});
if (argv.http_port !== null) {
require('http').createServer(function (req, res) {
var parts = url.parse(req.url, true);
if (!parts.pathname.startsWith('/channels/')) {
res.writeHead(404, {'Content-Type': 'text/plain'});
res.end('404 Not Found');
return;
}
var channels = parts.pathname.slice(10).split('|');
if (channels.length == 1 && !channels[0].length) {
res.writeHead(400, {'Content-Type': 'text/plain'});
res.end('400 Bad Request');
return;
}
req.channels = {};
req.last_msg = parseInt(parts.query.last);
if (isNaN(req.last_msg)) req.last_msg = 0;
channels.forEach(function (channel) {
req.channels[channel] = true;
});
req.on('close', function () {
pollers.remove(req);
});
req.got_message = function (message) {
if (message.channel in req.channels) {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify(message));
pollers.remove(req);
return true;
}
return false;
};
var got = false;
messages.each(function (message) {
if (!got && message.id > req.last_msg)
got = req.got_message(message);
});
if (!got) {
pollers.add(req);
res.setTimeout(comet_timeout, function () {
pollers.remove(req);
res.writeHead(504, {'Content-Type': 'application/json'});
res.end('{"error": "timeout"}');
});
}
}).listen(argv.http_port, argv.http_host);
}