initial commit of version 0.4

This commit is contained in:
Gene S
2015-10-22 10:00:39 -07:00
commit 1b3b5bdd1f
67 changed files with 13030 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
server/server
server/db/rethinkdb/rethinkdb_data
tinode-db/tinode-db
utils/utils

455
README.md Normal file
View File

@ -0,0 +1,455 @@
# Tinode Instant Messaging Server
**This documentation covers the next 0.4 release of Tinode. ETA mid-November 2015.**
Instant messaging server. Backend in pure [Go](http://golang.org) ([Affero GPL 3.0](http://www.gnu.org/licenses/agpl-3.0.en.html)), client-side binding in Java for Android and Javascript ([Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0)), persistent storage [RethinkDB](http://rethinkdb.com/), JSON over websocket. No UI components other than demo apps. Tinode is meant as a replacement for XMPP.
This is alpha-quality software. Bugs should be expected. Version 0.4
## Why?
[XMPP](http://xmpp.org/) is a mature specification with support for a very broad spectrum of use cases developed long before mobile became important. As a result most (all?) known XMPP servers are difficult to adapt for the most common use case of a few people messaging each other from mobile devices. Tinode is an attempt to build a modern replacement for XMPP/Jabber focused on a narrow use case of instant messaging between humans with emphasis on mobile communication.
## Features
### Supported
* One on one messaging
* Group chats:
* Groups (topics) with up to 32 members where every member's access permissions are managed individually
* Groups with unlimited number of members with bearer token access control
* Topic access control with separate permissions for various actions (reading, writing, sharing, etc)
* Server-generated presence notifications for people and topics
* Persistent message store
* Android Java bindings (dependencies: [jackson](https://github.com/FasterXML/jackson), [android-websockets](https://github.com/codebutler/android-websockets))
* Javascript bundings with no dependencies
* Websocket & long polling transport
* JSON wire protocol
* Server-generated message delivery status
* Support for client-side content caching
* Blocking users on the server
### Planned
* iOS client bindings
* Mobile push notification hooks
* Clustering
* Federation
* Multitenancy
* Different levels of message persistence (from strict persistence to purely ephemeral messaging)
* Support for binary wire protocol
* User search/discovery
* Anonymous clients
* Support for other SQL and NoSQL backends
* Pluggable authentication
## How it works?
Tinode is an IM router and a store. Conceptually it loosely follows a publish-subscribe model.
Server connects sessions, users, and topics. Session is a network connection between a client application and the server. User represents a human being who connects to the server with a session. Topic is a named communication channel which routes content between sessions.
Users and topics are assigned unique IDs. User ID is a string with 'usr' prefix followed by base64-URL-encoded pseudo-random 64-bit number, e.g. `usr2il9suCbuko`. Topic IDs are described below.
Clients such as mobile or web applications create sessions by connecting to the server over a websocket or through long polling. Client authentication is optional (*anonymous clients are technically supported but may not fully work as expected yet*). Client authenticates the session by sending a `{login}` packet. Only basic authentication with user name and password is currently supported. Multiple simultaneous sessions may be established by the same user. Logging out is not supported.
Once the session is established, the user can start interacting with other users through topics. The following
topic types are available:
* `me` is a topic for managing one's profile, receiving invites and requests for approval; 'me' topic exists for every user.
* Peer to peer topic is a communication channel strictly between two users. It's named as a 'p2p' prefix followed by a base64-URL-encoded numeric part of user IDs concatenated in ascending order, e.g. `p2p2il9suCbukqm4P2KFOv9-w`. Peer to peer topics must be explicitly created.
* Group topic is a channel for multi-user communication. It's named as 'grp' followed by 12 pseudo-random characters, i.e. `grp1XUtEhjv6HND`. Group topics must be explicitly created.
Session joins a topic by sending a `{sub}` packet. Packet `{sub}` serves three functions: creating a new topic, subscribing user to a topic, and attaching session to a topic. See {sub} section below for details.
Once the session has joined the topic, the user may start generating content by sending `{pub}` packets. The content is delivered to other attached sessions as `{data}` packets.
The user may query or update topic metadata by sending `{get}` and `{set}` packets.
Changes to topic metadata, such as changes in topic description, or when other users join or leave the topic, is reported to live sessions with `{pres}` (presence) packet.
When user's `me` topic comes online (i.e. an authenticated session attaches to `me` topic), a `{pres}` packet is sent to `me` topics of all other users, who have peer to peer subscriptions with the first user.
## Connecting to the server
Client establishes a connection to the server over HTTP. Server offers two end points:
* `/v0/channels` for websocket connections
* `/v0/v0/channels/lp` for long polling
`v0` denotes API version (currently zero). Every HTTP request must include API key in the request. It may be included in the URL as `...?apikey=<YOUR_API_KEY>`, in the request body, or in an HTTP header `X-Tinode-APIKey`.
Server responds to connection with a `{ctrl}` message. The `params` field contains protocol version:
`"params":{"ver":"0.4"}`
### Websocket
### Long polling
## Messages
A message is a logically associated set of data. Messages are passed as JSON-formatted text.
All client to server messages may have an optional `id` field. It's set by the client as means to receive an aknowledgement from the server that the message was received and processed. The `id` is expected to be a session-unique string but it can be any string. The server does not attempt to interpret it other than to check JSON validity. It's returned unchanged by the server when it replies to client messages.
For brievity the notation below omits double quotes around field names as well as outer curly brackets.
For messages that update application-defined data, such as `{set}` `private` or `public` fields, in case the server-side
data needs to be cleared, use a string with a single Unicode DEL character "&#x2421;" `"\u2421"`.
### Client to server messages
#### `{acc}`
Message `{acc}` is used for creating users or updating authentication credentials. To create a new user set
`acc.user` to string "new". Either authenticated or anonymous session can send an `{acc}` message to create a new user.
To update credentials leave `acc.user` unset.
```js
acc: {
id: "1a2b3", // string, client-provided message id, optional
user: "new", // string, "new" to create a new user, default: current user, optional
auth: [ // array of authentication schemes to add, update or delete
{
scheme: "basic", // requested authentication scheme for this account, required; only "basic" (default)
// is currently supported. The current basic scheme does not allow changes to username.
secret: "username:password" // string, secret for the chosen authentication scheme;
// to delete a scheme use string with a single DEL Unicode
// character "\u2421", required
}
],
init: { // object, user initialization data closely matching that of table initialization; optional
defacs: {
auth: "RWS", // string, default access mode for peer to peer conversations between
// this user and other authenticated users
anon: "X" // string, default access mode for peer to peer conversations between this user
// and anonymous (un-authenticated) users
}, // Default access mode for user's peer to peer topics
public: {}, // Free-form application-dependent payload to describe user, available to everyone
private: {} // Private application-dependent payload available to user only through `me` topic
}
}
```
Server responds with a `{ctrl}` message with `ctrl.params` containing details of the new user. If `init.acs` is missing,
server will assign server-default access values.
#### `{login}`
Login is used to authenticate the current session.
```js
login: {
id: "1a2b3", // string, client-provided message id, optional
scheme: "basic", // string, authentication scheme, optional; only "basic" (default)
// is currently supported
secret: "username:password", // string, secret for the chosen authentication scheme, required
expireIn: "24h", // string, login expiration time in Go's time.ParseDuration format, see below, optional
tag: "some string" // string, client instance ID; tag is used to support caching, optional
}
```
Basic authentication scheme expects `secret` to be a string composed of a user name followed by a colon `:` followed by a plan text password.
[time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) is used to parse `expireIn`. The recognized format is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
Server responds to a `{login}` packet with a `{ctrl}` packet.
#### `{sub}`
The `{sub}` packet serves three functions:
* creating a topic
* subscribing user to a topic
* attaching session to a topic
User creates a new group topic by sending `{sub}` packet with the `topic` field set to `new`. Server will create a topic and respond back to session with the name of the newly created topic.
User creates a new peer to peer topic by sending `{sub}` packet with `topic` set to peer's user ID.
The user is always subscribed to and the sessions is attached to the newly created topic.
If the user had no relationship with the topic, sending `{sub}` packet creates it. Subscribing means to establish a relationship between session's user and the topic when no relationship existed in the past.
Joining (attaching to) a topic means for the session to start consuming content from the topic. Server automatically differentiates between subscribing and joining/attaching based on context: if the user had no prior relationship with the topic, the server subscribes the user then attaches the current session to the topic. If relationship existed, the server only attaches the session to the topic. When subscribing, the server checks user's access permissions against topic's access control list. It may grant immediate access, deny access, may generate a request for approval from topic managers.
Server replies to the `{sub}` with a `{ctrl}`.
The `{sub}` message may include a `get` and `browse` fields which mirror `what` and `browse` fields of a {get} message. If included, server will treat them as a subsequent `{get}` message on the same topic. In that case the reply may also include `{meta}` and `{data}` messages.
```js
sub: {
id: "1a2b3", // string, client-provided message id, optional
topic: "me", // topic to be subscribed or attached to
// Object with topic initialization data, new topics & new
// subscriptions only, mirrors {set info}
init: {
defacs: {
auth: "RWS", // string, default access for new authenticated subscribers
anon: "X" // string, default access for new anonymous (un-authenticated) subscribers
}, // Default access mode for the new topic
public: {}, // Free-form application-dependent payload to describe topic
private: {} // Per-subscription private application-dependent payload
}, // object, optional
// Subscription parameters, mirrors {set sub}; sub.user must
// not be provided
sub: {
mode: "RWS", // string, requested access mode, optional; default: server-defined
info: {} // a free-form payload to pass to the topic manager
}, // object, optional
// Metadata to request from the topic; space-separated list, valid strings
// are "info", "sub", "data"; default: request nothing; unknown strings are
// ignored; see {get what} for details
get: "info sub data", // string, optional
// Optional parameters for get: "info", see {get what="data"} for details
browse: {
ascnd: true, // boolean, sort in ascending order by time, otherwise
// descending (default), optional
since: "2015-09-06T18:07:30.134Z", // datetime as RFC 3339-formated string,
// load objects newer than this (inclusive/closed), optional
before: "2015-10-06T18:07:30.134Z", // datetime as RFC 3339-formated string,
// load objects older than this (exclusive/open), optional
limit: 20, // integer, limit the number of returned objects, default: 32, optional
} // object, optional
}
```
#### `{leave}`
This is a counterpart to `{sub}` message. It also serves two functions:
* leaving the topic without unsubscribing (`unsub=false`)
* unsubscribing (`unsub=true`)
Server responds to `{leave}` with a `{ctrl}` packet. Leaving without unsubscribing affects just the current session. Leaving with unsubscribing will affect all user's sessions.
```js
leave: {
id: "1a2b3", // string, client-provided message id, optional
topic: "grp1XUtEhjv6HND", // string, topic to leave, unsubscribe, or
// delete, required
unsub: true // boolean, leave and unsubscribe, optional, default: false
```
#### `{pub}`
The message is used to distribute content to topic subscribers.
```js
pub: {
id: "1a2b3", // string, client-provided message id, optional
topic: "grp1XUtEhjv6HND", // topic to publish to, required
content: {} // object, free-form content to publish to topic
// subscribers, required
}
```
Topic subscribers receive the content as `{data}` message.
#### `{get}`
Query topic for metadata, such as description or a list of subscribers, or query message history.
```js
get: {
what: "sub info data", // string, space-separated list of parameters to query;
// unknown strings are ignored; required
browse: {
ascnd: true, // boolean, sort in ascending order by time, otherwise
// descending (default), optional
since: "2015-09-06T18:07:30.134Z", // datetime as RFC 3339-formated string,
// load objects newer than this (inclusive/closed), optional
before: "2015-10-06T18:07:30.134Z", // datetime as RFC 3339-formated string,
// load objects older than this (exclusive/open), optional
limit: 20, // integer, limit the number of returned objects, default: 32, optional
} // object, what=data query parameters
}
```
* `{get what="info"}`
Query topic description. Server responds with a `{meta}` message containing requested data. See `{meta}` for details.
* `{get what="sub"}`
Get a list of subscribers. Server responds with a `{meta}` message containing a list of subscribers. See `{meta}` for details.
For `me` topic the request returns a list of user's subscriptions.
* `{get what="data"}`
Query message history. Server sends `{data}` messages matching parameters provided in the `browse` field of the query.
The `id` field of the data messages is not provided as it's common for data messages.
#### `{set}`
Update topic metadata, delete messages or topic.
```js
set: {
id: "1a2b3", // string, client-provided message id, optional
topic: "grp1XUtEhjv6HND", // string, topic to publish to, required
what: "sub info", // string, space separated list of data to update,
// unknown strings are ignored
info: {}, // object, payload for what == "info"
sub: {} // object, payload for what == "sub"
}
```
#### `{del}`
Delete messages or topic.
```js
del: {
id: "1a2b3", // string, client-provided message id, optional
topic: "grp1XUtEhjv6HND", // string, topic affect, required
what: "msg", // string, either "topic" or "msg" (default); what to delete - the
// entire topic or just the messages, optional, default: "msg"
hard: false, // boolean, request to delete messages for all users, default: false
before: "2015-10-06T18:07:30.134Z", // datetime as a RFC 3339-
// formated string, delete messages older than this
// (exclusive of the value itself), optional
}
```
No special permission is needed to soft-delete messages `hard=false`. Soft-deleting messages hide them from the
requesting user. `D` permission is needed to hard-delete messages. Only owner can delete a topic.
### Server to client messages
Messages to a session generated in response to a specific request contain an `id` field equal to the id of the
originating message. The `id` is not interpreted by the server.
Most server to client messages have a `ts` field which is a timestamp when the message was generated by the server. Timestamp is in [RFC 3339](https://tools.ietf.org/html/rfc3339) format, timezone is always UTC, precision to milliseconds.
#### `{data}`
Content published in the topic. These messages are the only messages persisted in database; `{data}` messages are
broadcast to all topic subscribers with an `R` permission.
```js
data: {
topic: "grp1XUtEhjv6HND", // string, topic which distributed this message,
// always present
from: "usr2il9suCbuko", // string, id of the user who published the
// message; could be missing if the message was
// generated by the server
ts: "2015-10-06T18:07:30.038Z", // string, timestamp
content: {} // object, content exactly as published by the user in the
// {pub} message
}
```
#### `{ctrl}`
Generic response indicating an error or a success condition. The message is sent to the originating session.
```js
ctrl: {
id: "1a2b3", // string, client-provided message id, optional
topic: "grp1XUtEhjv6HND", // string, topic name, if this is a response in context of a topic, optional
code: 200, // integer, code indicating success or failure of the request, follows the HTTP status
//codes model, always present
text: "OK", // string, text with more details about the result, always present
params: {}, // object, generic response parameters, context-dependent, optional
ts: "2015-10-06T18:07:30.038Z", // string, timestamp
}
```
#### `{meta}`
Information about topic metadata or subscribers, sent in response to `{set}` or `{sub}` message to the originating session.
```js
ctrl: {
id: "1a2b3", // string, client-provided message id, optional
topic: "grp1XUtEhjv6HND", // string, topic name, if this is a response in context of a topic, optional
info: {
}, // object, topic description, optional
sub: [
] // array of objects, topic subscribers, optional
ts: "2015-10-06T18:07:30.038Z", // string, timestamp
}
```
#### `{pres}`
Notification that topic metadata has changed. Timestamp is not present.
```js
pres: {
topic: "grp1XUtEhjv6HND", // string, topic affected by the change, always present
user: "usr2il9suCbuko", // user affected by the change, present if relevant
what: "" // string, what's changed, always present
}
```
## Access control
Access control manages user's access to topics through access control lists (ACLs) or bearer tokens (not implemented as of version 0.4).
User's access to a topic is defined by two sets of permissions: user's desired permissions, and topic's given permissions. Each permission is a bit in a bitmap. It can be either present or absent. The actual access is determined as a bitwise AND of wanted and given permissions. The permissions are represented as a set of symbols, where presence of a symbol means a set permission bit:
* No access: `N` is not a permission per se but an indicator that permissions are explicitly cleared/not set. It usually means that the default permissions should not be applied.
* Read: `R`, permission to receive `{data}` packets
* Write: `W`, permission to `{pub}` to topic
* Presence: `P`, permission to receive presence updates `{pres}`
* Sharing: `S`, permission to invite other people to join a topic and to approve requests to join
* Delete: `D`, permission to hard-delete messages; only owners can completely delete topics
* Owner: `O`, user is the topic owner
* Banned: `X`, user has no access, requests to share/gain access/`{sub}` are silently ignored
Topic's default access is established at the topic creation time by `{sub.init.acs}` field and can be subsequently modified by `{set}` messages. Default access is applied to all new subscriptions. It can be assigned on a per-user basis by `{set}` messages.
## Topics
Topic is a named communication channel for one or more people. All timestamps are represented as RFC 3999-formatted string with precision to milliseconds and timezone always set to UTC, e.g. `"2015-10-06T18:07:29.841Z"`.
### `me` topic
Topic `me` is automatically created for every user at the account creation time. It serves as means for account updates, receiving presence notification from people and topics of interest, invites to join topics, requests to approve subscription for topics where this user is a manager (has `S` permission). Topic `me` cannot be deleted or unsubscribed from. One can leave the topic which will stop all relevant communication and indicate that the user is offline (although the user may still be logged in and may continue to use other topics).
Joining or leaving `me` generates a `{pres}` presence update sent to all users who have peer to peer topics with the given user with appropriate permissions.
Topic `me` is read-only. `{pub}` messages to `me` are rejected.
The `{data}` message represents invites and requests to confirm a subscription. The `from` field of the message contains ID of the user who originated the request, for instance, the user who asked current user to join a topic or the user who requested an approval for subscription. The `content` field of the message contains the following information:
* act: request action as string; possible actions are:
* "info" to notify the user that user's request to subscribe was approved; in case of peer to peer topics this could be a notification that the peer has subscribed to the topic
* "join" is an invitation to subscribe to a topic
* "appr" is a request to approve a subscription
* topic: the name of the topic, in case of an invite the current user is invited to this topic; in case of a request to approve, another user wants to subscribe to this topic where the current user is a manager (has `S` permission)
* user: user ID as a string of the user who is the target of this request. In case of an invite this is the ID of the current user; in case of an approval request this is the ID of the user who is being subscribed.
* acs: object describing access permissions of the subscription, see [Access control][] for details
* info: object with a free-form payload. It's passed unchanged from the originating `{sub}` or `{set}` request.
Message `{get what="info"}` to `me` is automatically replied with a `{meta}` message containing `info` section with the following information:
* created: timestamp of topic creation time
* updated: timestamp of when topic's `public` or `private` was last updated
* acs: object describing user's access permissions, `{"want":"RDO","given":"RDO"}`, see [Access control][] for details
* lastMsg: timestamp when last `{data}` message was sent through the topic
* seen: an object describing when the topic was last accessed by the current user from any client instance. This should be used if the client implements data caching. See [Support for Client-Side Caching] for more details.
* when": timestamp of the last access
* tag: string provided by the client instance when it accessed the topic.
* seenTag: timestamp when the topic was last accessed from a session with the current client instance. See [Support for Client-Side Caching] for more details
* public: an object with application-defined content which describes the user, such user name "Jane Doe" or any other information which is made freely available to other users.
* private: an object with application-defined content which is made available only to user's own sessions.
Message `{get what="sub"}` to `me` is different from any other topic as it returns the list of topics that the current user is subscribed to as opposite to the user's subscription to `me`.
Message `{get what="data"}` to `me` queries the history of invites/notifications. It's handled the same way as to any other topic.
### Peer to Peer Topics `p2pAAABBB`
### Group Topics `grpABCDEFG`
## Support for Client-Side Caching

View File

@ -0,0 +1,135 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File :
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Root object for communication with the server
*
*****************************************************************************/
package com.tinode;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Date;
public final class Tinode {
private static String sApiKey;
private static URL sServerUrl;
private static String sAuthToken;
private static Date sAuthExpires;
private static String sMyId;
private static ObjectMapper sJsonMapper;
private static JsonFactory sJsonFactory;
private static TypeFactory sTypeFactory;
private Tinode() {
}
/**
* Initialize Tinode package
*
* @param url debuggin only. will be hardcoded and removed from here
* @param key api key provided by Tinode
*/
public static void initialize(URL url, String key) {
sJsonMapper = new ObjectMapper();
// Silently ignore unknown properties:
sJsonMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// Skip null fields from serialization:
sJsonMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
sTypeFactory = sJsonMapper.getTypeFactory();
sApiKey = key;
sServerUrl = url;
}
public static String getApiKey() {
return sApiKey;
}
public static URL getEndpointUrl() {
return sServerUrl;
}
public static URI getEndpointUri() {
try {
return sServerUrl.toURI();
} catch (URISyntaxException e) {
return null;
}
}
synchronized public static void setAuthParams(String myId, String token, Date expires) {
sMyId = myId;
sAuthToken = token;
sAuthExpires = expires;
}
synchronized public static void clearAuthParams() {
sMyId = null;
sAuthToken = null;
sAuthExpires = null;
}
synchronized public static String getAuthToken() {
if (sAuthToken != null) {
Date now = new Date();
if (!sAuthExpires.before(now)) {
return sAuthToken;
} else {
sAuthToken = null;
sAuthExpires = null;
}
}
return null;
}
public static String getMyId() {
return sMyId;
}
public static void clearMyId() {
sMyId = null;
}
public static boolean isAuthenticated() {
return (sMyId != null);
}
public static TypeFactory getTypeFactory() {
return sTypeFactory;
}
public static ObjectMapper getJsonMapper() {
return sJsonMapper;
}
}

View File

@ -0,0 +1,136 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : Request.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.rest;
import android.util.Log;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.tinode.Tinode;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
/**
* REST request constructor
*/
public class Request {
private static final String TAG = "com.tinode.rest.Request";
protected String mMethod;
protected Object mPayload;
protected URL mUrl;
protected JavaType mBaseType;
protected boolean mIsList;
protected Request(URL endpoint) {
mUrl = endpoint;
mIsList = true;
}
public static Request build() {
return new Request(Tinode.getEndpointUrl());
}
public Request setEndpoint(URL endpoint) {
mUrl = endpoint;
mIsList = true;
return this;
}
public Request setMethod(String method) {
mMethod = method;
return this;
}
public Request addKind(String kind) {
try {
String path = mUrl.getPath();
if (path.equals("")) {
path = "/";
} else if (path.lastIndexOf('/') != (path.length() - 1)) {
// Ensure path is terminated by slash
path = path + "/";
}
mUrl = new URL(mUrl, path + kind);
mIsList = true;
} catch (MalformedURLException e) {
Log.i(TAG, e.toString());
}
return this;
}
public Request addObjectId(String id) {
try {
mUrl = new URL(mUrl, mUrl.getPath() + "/:" + id);
mIsList = false;
} catch (MalformedURLException e) {
Log.i(TAG, e.toString());
}
return this;
}
public String getMethod() {
return mMethod;
}
public URL getUrl() {
return mUrl;
}
public Object getPayload() {
return mPayload;
}
public Request setPayload(Object payload) {
mPayload = payload;
return this;
}
public Request setInDataType(JavaType type) {
mBaseType = type;
return this;
}
public Request setInDataType(Class<?> type) {
mBaseType = Tinode.getTypeFactory().constructType(type);
return this;
}
public JavaType getInDataType() {
TypeFactory tf = Tinode.getTypeFactory();
if (mBaseType == null) {
mBaseType = tf.constructType(Object.class);
}
if (mIsList) {
return tf.constructCollectionType(ArrayList.class, mBaseType);
} else {
return mBaseType;
}
}
}

View File

@ -0,0 +1,103 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : Rest.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.rest;
import android.os.AsyncTask;
import android.util.Log;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tinode.Tinode;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
/**
* Execute REST request, return response
*/
public class Rest {
private static final String TAG = "com.tinode.rest.Rest";
public static final String METHOD_GET = "GET";
public static final String METHOD_POST = "POST";
public static final String METHOD_PUT = "PUT";
public static final String METHOD_DELETE = "DELETE";
protected Rest() {
}
public static Request buildRequest() {
return Request.build();
}
public static <T> T executeBlocking(Request req) {
URL url = req.getUrl();
Log.d(TAG, "Requesting " + url.toString());
ObjectMapper mapper = Tinode.getJsonMapper();
try {
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
try {
conn.setRequestMethod(req.getMethod());
Object payload = req.getPayload();
conn.setDoOutput(payload != null);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("X-Tinode-APIKey", Tinode.getApiKey());
conn.setRequestProperty("X-Tinode-Token", Tinode.getAuthToken());
if (payload != null) {
OutputStreamWriter out = new OutputStreamWriter(conn.getOutputStream(), "UTF-8");
mapper.writeValue(out, payload);
out.flush();
out.close();
Log.d(TAG, "Sent: " + payload.toString());
}
int code = conn.getResponseCode();
BufferedReader bin = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));
T val = mapper.readValue(bin, req.getInDataType());
bin.close();
return val;
} catch (MalformedURLException e) {
Log.i(TAG, e.toString());
} finally {
conn.disconnect();
}
} catch (IOException e) {
Log.i(TAG, e.toString());
}
return null;
}
}

View File

@ -0,0 +1,52 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : Contact.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.rest.model;
import java.util.ArrayList;
import java.util.Date;
/**
* A representation of a contact in an address book
*/
public class Contact {
public String id; // id of contact record
public String contactId; // Id of the user
public String parentId; // id of the user who created this contact
public String name;
public Date createdAt;
public boolean active;
public ArrayList<String> tags;
public String comment;
public Contact() {
tags = new ArrayList<String>();
}
public String toString() {
if (name != null) {
return name;
} else {
return contactId.substring(0, 10) + "...";
}
}
}

View File

@ -0,0 +1,940 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : Connection.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.streaming;
import android.os.Handler;
import android.support.v4.util.SimpleArrayMap;
import android.util.ArrayMap;
import android.util.Log;
import com.codebutler.android_websockets.WebSocketClient;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.tinode.Tinode;
import com.tinode.streaming.model.ClientMessage;
import com.tinode.streaming.model.MsgClientLogin;
import com.tinode.streaming.model.MsgClientPub;
import com.tinode.streaming.model.MsgClientSub;
import com.tinode.streaming.model.MsgClientUnsub;
import com.tinode.streaming.model.MsgServerCtrl;
import com.tinode.streaming.model.MsgServerData;
import com.tinode.streaming.model.ServerMessage;
import org.apache.http.message.BasicNameValuePair;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Map;
import java.util.Random;
/**
* Singleton class representing a streaming communication channel between a client and a server
*
* First call {@link Tinode#initialize(java.net.URL, String)}, then call {@link #getInstance()}
*
* Created by gene on 2/12/14.
*/
public class Connection {
private static final String TAG = "com.tinode.Connection";
private static Connection sConnection;
private int mPacketCount = 0;
private int mMsgId = 0;
private EventListener mListener;
private Handler mHandler;
private WebSocketClient mWsClient;
// A list of outstanding requests, indexed by id
protected SimpleArrayMap<String, Cmd> mRequests;
// List of live subscriptions, key=[topic name], value=[topic, subscribed or
// subscription pending]
protected ArrayMap<String, Topic<?>> mSubscriptions;
// Exponential backoff/reconnecting
// TODO(gene): implement autoreconnect
private boolean autoreconnect;
private ExpBackoff backoff;
public static final String TOPIC_NEW = "!new";
public static final String TOPIC_ME = "!me";
public static final String TOPIC_PRES = "!pres";
public static final String TOPIC_P2P = "!usr:";
protected Connection(URI endpoint, String apikey) {
mRequests = new SimpleArrayMap<String, Cmd>();
mSubscriptions = new ArrayMap<String, Topic<?>>();
String path = endpoint.getPath();
if (path.equals("")) {
path = "/";
} else if (path.lastIndexOf("/") != path.length() - 1) {
path += "/";
}
path += "channels"; // http://www.example.com/v0/channels
URI uri;
try {
uri = new URI(endpoint.getScheme(),
endpoint.getUserInfo(),
endpoint.getHost(),
endpoint.getPort(),
path,
endpoint.getQuery(),
endpoint.getFragment());
} catch (URISyntaxException e) {
e.printStackTrace();
return;
}
mWsClient = new WebSocketClient(uri, new WebSocketClient.Listener() {
@Override
public void onConnect() {
Log.d(TAG, "Websocket connected!");
if (mHandler != null) {
mHandler.post(new Runnable() {
@Override
public void run() {
onWsConnect();
}
});
} else {
onWsConnect();
}
}
@Override
public void onMessage(final String message) {
Log.d(TAG, message);
mPacketCount ++;
if (mHandler != null) {
mHandler.post(new Runnable() {
@Override
public void run() {
onWsMessage(message);
}
});
} else {
onWsMessage(message);
}
}
@Override
public void onMessage(byte[] data) {
// do nothing, server does not send binary frames
Log.i(TAG, "binary message received (should not happen)");
}
@Override
public void onDisconnect(final int code, final String reason) {
Log.d(TAG, "Disconnected :(");
// Reset packet counter
mPacketCount = 0;
if (mHandler != null) {
mHandler.post(new Runnable() {
@Override
public void run() {
onWsDisconnect(code, reason);
}
});
} else {
onWsDisconnect(code, reason);
}
if (autoreconnect) {
// TODO(gene): add autoreconnect
}
}
@Override
public void onError(final Exception error) {
Log.i(TAG, "Connection error", error);
if (mHandler != null) {
mHandler.post(new Runnable() {
@Override
public void run() {
onWsError(error);
}
});
} else {
onWsError(error);
}
}
}, Arrays.asList(new BasicNameValuePair("X-Tinode-APIKey", apikey)));
}
protected void onWsConnect() {
if (backoff != null) {
backoff.reset();
}
}
protected void onWsMessage(String message) {
ServerMessage pkt = parseServerMessageFromJson(message);
if (pkt == null) {
Log.i(TAG, "Failed to parse packet");
return;
}
boolean dispatchDone = false;
if (pkt.ctrl != null) {
if (mPacketCount == 1) {
// The first packet from a fresh connection
if (mListener != null) {
mListener.onConnect(pkt.ctrl.code, pkt.ctrl.text, pkt.ctrl.getParams());
dispatchDone = true;
}
}
} else if (pkt.data == null) {
Log.i(TAG, "Empty packet received");
}
if (!dispatchDone) {
// Dispatch message to topics
if (mListener != null) {
dispatchDone = mListener.onMessage(pkt);
}
if (!dispatchDone) {
dispatchPacket(pkt);
}
}
}
protected void onWsDisconnect(int code, String reason) {
// Dump all records of pending requests, responses won't be coming anyway
mRequests.clear();
// Inform topics that they were disconnected, clear list of subscribe topics
disconnectTopics();
mSubscriptions.clear();
Tinode.clearMyId();
if (mListener != null) {
mListener.onDisconnect(code, reason);
}
}
protected void onWsError(Exception err) {
// do nothing
}
/**
* Listener for connection-level events. Don't override onMessage for default behavior.
*
* By default all methods are called on the websocket thread. If you want to change that
* set handler with {@link #setHandler(android.os.Handler)}
*/
public void setListener(EventListener l) {
mListener = l;
}
public EventListener getListener() {
return mListener;
}
/**
* By default all {@link Connection.EventListener} methods are called on websocket thread.
* If you want to change that and, for instance, have them called on the main application thread, do something
* like this: {@code mConnection.setHandler(new Handler(Looper.getMainLooper()));}
*
* @param h handler to use
* @see android.os.Handler
* @see android.os.Looper
*/
public void setHandler(Handler h) {
mHandler = h;
}
/**
* Get an instance of Connection if it already exists or create a new instance.
*
* @return Connection instance
*/
public static Connection getInstance() {
if (sConnection == null) {
sConnection = new Connection(Tinode.getEndpointUri(), Tinode.getApiKey());
}
return sConnection;
}
/**
* Establish a connection with the server. It opens a websocket in a separate
* thread. Success or failure will be reported through callback set by
* {@link #setListener(Connection.EventListener)}.
*
* This is a non-blocking call.
*
* @param autoreconnect not implemented yet
* @return true if a new attempt to open a connection was performed, false if connection already exists
*/
public boolean Connect(boolean autoreconnect) {
// TODO(gene): implement autoreconnect
this.autoreconnect = autoreconnect;
if (!mWsClient.isConnected()) {
mWsClient.connect();
return true;
}
return false;
}
/**
* Gracefully close websocket connection
*
* @return true if an actual attempt to disconnect was made, false if there was no connection already
*/
public boolean Disconnect() {
if (mWsClient.isConnected()) {
mWsClient.disconnect();
return true;
}
return false;
}
/**
* Send a basic login packet to the server. A connection must be established prior to calling
* this method. Success or failure will be reported through {@link Connection.EventListener#onLogin(int, String)}
*
* @param uname user name
* @param password password
* @return id of the message (which is either "login" or null)
* @throws NotConnectedException if there is no connection
*/
public String Login(String uname, String password) throws NotConnectedException {
return login(MsgClientLogin.LOGIN_BASIC, MsgClientLogin.makeToken(uname, password));
}
/**
* Send a token login packet to server. A connection must be established prior to calling
* this method. Success or failure will be reported through {@link Connection.EventListener#onLogin(int, String)}
*
* @param token a previously obtained or generated login token
* @return id of the message (which is either "login" or null)
* @throws NotConnectedException if there is not connection
*/
public String Login(String token) throws NotConnectedException {
return login(MsgClientLogin.LOGIN_TOKEN, token);
}
protected String login(String scheme, String secret) throws NotConnectedException {
ClientMessage msg = new ClientMessage();
msg.login = new MsgClientLogin();
msg.login.setId("login");
msg.login.Login(scheme, secret);
try {
send(Tinode.getJsonMapper().writeValueAsString(msg));
expectReply(Cmd.LOGIN, "login", null);
return "login";
} catch (JsonProcessingException e) {
return null;
}
}
/**
* Execute subscription request for a topic.
*
* Users should call {@link Topic#Subscribe()} instead
*
* @param topic to subscribe
* @return request id
* @throws NotConnectedException
*/
protected String subscribe(Topic<?> topic) throws NotConnectedException {
wantAkn(true); // Message dispatching to Topic<?> requires acknowledgements
String name = topic.getName();
if (name == null || name.equals("")) {
Log.i(TAG, "Empty topic name");
return null;
}
String id = Subscribe(name);
if (id != null) {
expectReply(Cmd.SUB, id, topic);
}
return id;
}
/**
* Low-level subscription request. The subsequent messages on this topic will not
* be automatically dispatched. A {@link Topic#Subscribe()} should be normally used instead.
*
* @param topicName name of the topic to subscribe to
* @return id of the sent subscription packet, if {@link #wantAkn(boolean)} is set to true, null otherwise
* @throws NotConnectedException
*/
public String Subscribe(String topicName) throws NotConnectedException {
ClientMessage msg = new ClientMessage();
msg.sub = new MsgClientSub(topicName);
String id = getNextId();
msg.sub.setId(id);
try {
send(Tinode.getJsonMapper().writeValueAsString(msg));
return id;
} catch (JsonProcessingException e) {
Log.i(TAG, "Failed to serialize message", e);
return null;
}
}
protected String unsubscribe(Topic<?> topic) throws NotConnectedException {
wantAkn(true);
String id = Unsubscribe(topic.getName());
if (id != null) {
expectReply(Cmd.UNSUB, id, topic);
}
return id;
}
/**
* Low-level request to unsubscribe topic. A {@link com.tinode.streaming.Topic#Unsubscribe()} should be normally
* used instead.
*
* @param topicName name of the topic to subscribe to
* @return id of the sent subscription packet, if {@link #wantAkn(boolean)} is set to true, null otherwise
* @throws NotConnectedException
*/
public String Unsubscribe(String topicName) throws NotConnectedException {
ClientMessage msg = new ClientMessage();
msg.unsub = new MsgClientUnsub(topicName);
msg.unsub.setId(getNextId());
mSubscriptions.remove(topicName);
try {
send(Tinode.getJsonMapper().writeValueAsString(msg));
return msg.unsub.getId();
} catch (JsonProcessingException e) {
return null;
}
}
protected String publish(Topic<?> topic, Object content) throws NotConnectedException {
wantAkn(true);
String id = Publish(topic.getName(), content);
if (id != null) {
expectReply(Cmd.PUB, id, topic);
}
return id;
}
/**
* Low-level request to publish data. A {@link Topic#Publish(Object)} should be normally
* used instead.
*
* @param topicName name of the topic to publish to
* @param data payload to publish to topic
* @return id of the sent packet, if {@link #wantAkn(boolean)} is set to true, null otherwise
* @throws NotConnectedException
*/
public String Publish(String topicName, Object data) throws NotConnectedException {
ClientMessage msg = new ClientMessage();
msg.pub = new MsgClientPub<Object>(topicName, data);
msg.pub.setId(getNextId());
try {
send(Tinode.getJsonMapper().writeValueAsString(msg));
return msg.pub.getId();
} catch (JsonProcessingException e) {
return null;
}
}
/**
* Assigns packet id, if needed, converts {@link com.tinode.streaming.model.ClientMessage} to Json string,
* then calls {@link #send(String)}
*
* @param msg message to send
* @return id of the packet (could be null)
* @throws NotConnectedException
*/
protected String sendPacket(ClientMessage<?> msg) throws NotConnectedException {
String id = getNextId();
if (id !=null) {
if (msg.pub != null) {
msg.pub.setId(id);
} else if (msg.sub != null) {
msg.sub.setId(id);
} else if (msg.unsub != null) {
msg.unsub.setId(id);
} else if (msg.login != null) {
msg.login.setId(id);
}
}
try {
send(Tinode.getJsonMapper().writeValueAsString(msg));
return id;
} catch (JsonProcessingException e) {
return null;
}
}
/**
* Writes a string to websocket.
*
* @param data string to write to websocket
* @throws NotConnectedException
*/
protected void send(String data) throws NotConnectedException {
if (mWsClient.isConnected()) {
mWsClient.send(data);
} else {
throw new NotConnectedException("Send called without a live connection");
}
}
/**
* Request server to send acknowledgement packets. Server responds with such packets if
* client includes non-empty id fiel0d into outgoing packets.
* If set to true, {@link #getNextId()} will return a string representation of a random integer between
* 16777215 and 33554430
*
* @param akn true to request akn packets, false otherwise
* @return previous value
*/
public boolean wantAkn(boolean akn) {
boolean prev = (mMsgId != 0);
if (akn) {
mMsgId = 0xFFFFFF + (int) (Math.random() * 0xFFFFFF);
} else {
mMsgId = 0;
}
return prev;
}
/**
* Makes a record of an outgoing packet. This is used to match requests to replies.
*
* @param type packet type, see constants in {@link Cmd}
* @param id packet id
* @param topic topic (could be null)
*/
protected void expectReply(int type, String id, Topic<?> topic) {
mRequests.put(id, new Cmd(type, id, topic));
}
/**
* Check if there is a live connection.
*
* @return true if underlying websocket is connected
*/
public boolean isConnected() {
return mWsClient.isConnected();
}
/**
* Get token returned by the server after successful authentication.
*
* @return security token
*/
public String getAuthToken() {
return Tinode.getAuthToken();
}
/**
* Get ID of the currently authenticated user.
*
* @return user ID
*/
public String getMyUID() {
return Tinode.getMyId();
}
/**
* Check if connection has been authenticated
*
* @return true, if connection was authenticated (Login was successfully called), false otherwise
*/
public boolean isAuthenticated() {
return Tinode.isAuthenticated();
}
/**
* Obtain a subscribed !me topic ({@link MeTopic}).
*
* @return subscribed !me topic or null if !me is not subscribed
*/
public MeTopic<?> getSubscribedMeTopic() {
return (MeTopic) mSubscriptions.get(TOPIC_ME);
}
/**
* Obtain a subscribed !pres topic {@link PresTopic}.
*
* @return subscribed !pres topic or null if !pres is not subscribed
*/
public PresTopic<?> getSubscribedPresTopic() {
return (PresTopic) mSubscriptions.get(TOPIC_PRES);
}
/**
* Obtain a subscribed topic by name
*
* @param name name of the topic to find
* @return subscribed topic or null if no such topic was found
*/
public Topic<?> getSubscribedTopic(String name) {
return mSubscriptions.get(name);
}
/**
* Returns {@link com.fasterxml.jackson.databind.type.TypeFactory} which can be used to define complex types.
* This is needed only if payload in {@link MsgClientPub} is complex, such as {@link java.util.Collection} or other
* generic class.
*
* @return TypeFactory
*/
public TypeFactory getTypeFactory() {
return Tinode.getTypeFactory();
}
/**
* Finds topic for the packet and calls topic's {@link Topic#dispatch(ServerMessage)} method.
* This method can be safely called from the UI thread after overriding
* {@link Connection.EventListener#onMessage(ServerMessage)}
*
* There are two types of messages:
* <ul>
* <li>Control packets in response to requests sent by this client</li>
* <li>Data packets</li>
* </ul>
*
* This method dispatches control packets by matching id of the message with a map of
* outstanding requests.<p/>
* The data packets are dispatched by topic name. If topic is unknown,
* an onNewTopic is fired, then message is dispatched to the topic it returns.
*
* @param pkt packet to be dispatched
* @return true if packet was successfully dispatched, false if topic was not found
*/
@SuppressWarnings("unchecked")
public boolean dispatchPacket(ServerMessage<?> pkt) {
Log.d(TAG, "dispatchPacket: processing message");
if (pkt.ctrl != null) {
Log.d(TAG, "dispatchPacket: control");
// This is a response to previous action
String id = pkt.getId();
if (id != null) {
Cmd cmd = mRequests.remove(id);
if (cmd != null) {
switch (cmd.type) {
case Cmd.LOGIN:
if (pkt.ctrl.code == 200) {
Tinode.setAuthParams(pkt.ctrl.getStringParam("uid"),
pkt.ctrl.getStringParam("token"),
pkt.ctrl.getDateParam("expires"));
}
if (mListener != null) {
mListener.onLogin(pkt.ctrl.code, pkt.ctrl.text);
}
return true;
case Cmd.SUB:
if (pkt.ctrl.code >= 200 && pkt.ctrl.code < 300) {
if (TOPIC_NEW.equals(cmd.source.getName())) {
// Replace "!new" with the actual topic name
cmd.source.setName(pkt.getTopic());
}
mSubscriptions.put(cmd.source.getName(), cmd.source);
Log.d(TAG, "Sub completed: " + cmd.source.getName());
}
return cmd.source.dispatch(pkt);
case Cmd.UNSUB:
// This could fail for two reasons:
// 1. Not subscribed
// 2. Something else
// Either way, no need to remove topic from subscription in case of
// failure
if (pkt.ctrl.code >= 200 && pkt.ctrl.code < 300) {
mSubscriptions.remove(cmd.source.getName());
}
return cmd.source.dispatch(pkt);
case Cmd.PUB:
return cmd.source.dispatch(pkt);
}
}
} else {
Log.i(TAG, "Unexpected control packet");
}
} else if (pkt.data != null) {
// This is a new data packet
String topicName = pkt.getTopic();
Log.d(TAG, "dispatchPacket: data for " + topicName);
if (topicName != null) {
// The topic must be in the list of subscriptions already.
// It must have been created in {@link #pareseMsgServerData} or earlier
Topic topic = mSubscriptions.get(topicName);
if (topic != null) {
// This generates the "unchecked" warning
return topic.dispatch(pkt);
} else {
Log.i(TAG, "Packet for unknown topic " + topicName);
}
}
}
return false;
}
protected void registerP2PTopic(MeTopic<?> topic) {
mSubscriptions.put(topic.getName(), topic);
}
/**
* Enumerate subscribed topics and inform each one that it was disconnected.
*/
private void disconnectTopics() {
for (Map.Entry<String, Topic<?>> e : mSubscriptions.entrySet()) {
e.getValue().disconnected();
}
}
/**
* Parse JSON received from the server into {@link ServerMessage}
*
* @param jsonMessage
* @return ServerMessage or null
*/
@SuppressWarnings("unchecked")
protected ServerMessage<?> parseServerMessageFromJson(String jsonMessage) {
MsgServerCtrl ctrl = null;
MsgServerData<?> data = null;
try {
ObjectMapper mapper = Tinode.getJsonMapper();
JsonParser parser = mapper.getFactory().createParser(jsonMessage);
// Sanity check: verify that we got "Json Object":
if (parser.nextToken() != JsonToken.START_OBJECT) {
throw new JsonParseException("Packet must start with an object",
parser.getCurrentLocation());
}
// Iterate over object fields:
while (parser.nextToken() != JsonToken.END_OBJECT) {
String name = parser.getCurrentName();
parser.nextToken();
if (name.equals("ctrl")) {
ctrl = mapper.readValue(parser, MsgServerCtrl.class);
} else if (name.equals("data")) {
data = parseMsgServerData(parser);
} else { // Unrecognized field, ignore
Log.i(TAG, "Unknown field in packet: '" + name +"'");
}
}
parser.close(); // important to close both parser and underlying reader
} catch (JsonParseException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
if (ctrl != null) {
return new ServerMessage(ctrl);
} else if (data != null) {
// This generates the "unchecked" warning
return new ServerMessage(data);
}
return null;
}
protected MsgServerData<?> parseMsgServerData(JsonParser parser) throws JsonParseException,
IOException {
ObjectMapper mapper = Tinode.getJsonMapper();
JsonNode data = mapper.readTree(parser);
if (data.has("topic")) {
String topicName = data.get("topic").asText();
Topic<?> topic = getSubscribedTopic(topicName);
// Is this a topic we are subscribed to?
if (topic == null) {
// This is a new topic
// Try to find a topic pending subscription by packet id
if (data.has("id")) {
String id = data.get("id").asText();
Cmd cmd = mRequests.get(id);
if (cmd != null) {
topic = cmd.source;
}
}
// If topic was not found among pending subscriptions, try to create it
if (topic == null && mListener != null) {
topic = mListener.onNewTopic(topicName);
if (topic != null) {
topic.setStatus(Topic.STATUS_SUBSCRIBED);
mSubscriptions.put(topicName, topic);
} else if (topicName.startsWith(TOPIC_P2P)) {
// Client refused to create topic. If this is a P2P topic, assume
// the payload is the same as "!me"
topic = getSubscribedMeTopic();
}
}
}
JavaType typeOfData;
if (topic == null) {
Log.i(TAG, "Data message for unknown topic [" + topicName + "]");
typeOfData = mapper.getTypeFactory().constructType(Object.class);
} else {
typeOfData = topic.getDataType();
}
MsgServerData packet = new MsgServerData();
if (data.has("id")) {
packet.id = data.get("id").asText();
}
packet.topic = topicName;
if (data.has("origin")) {
packet.origin = data.get("origin").asText();
}
if (data.has("content")) {
packet.content = mapper.readValue(data.get("content").traverse(), typeOfData);
}
return packet;
} else {
throw new JsonParseException("Invalid data packet: missing topic name",
parser.getCurrentLocation());
}
}
/**
* Get a string representation of a random number, to be used as a packet id.
*
* @return reasonably unique id
* @see #wantAkn(boolean)
*/
synchronized private String getNextId() {
if (mMsgId == 0) {
return null;
}
return String.valueOf(++mMsgId);
}
static class Cmd {
static final int LOGIN = 1;
static final int SUB = 2;
static final int UNSUB = 3;
static final int PUB = 4;
int type;
String id;
Topic<?> source;
Cmd(int type, String id, Topic<?> src) {
this.type = type;
this.id = id;
this.source = src;
}
Cmd(int type, String id) {
this(type, id, null);
}
}
/**
* Callback interface called by Connection when it receives events from the websocket.
*
*/
public static abstract class EventListener {
/**
* Connection was established successfully
*
* @param code should be always 201
* @param reason should be always "Created"
* @param params server parameters, such as protocol version
*/
public abstract void onConnect(int code, String reason, Map<String, Object> params);
/**
* Connection was dropped
*
* @param code numeric code of the error which caused connection to drop
* @param reason error message
*/
public abstract void onDisconnect(int code, String reason);
/**
* Result of successful or unsuccessful {@link #Login(String)} attempt.
*
* @param code a numeric value between 200 and 2999 on success, 400 or higher on failure
* @param text "OK" on success or error message
*/
public abstract void onLogin(int code, String text);
/**
* A request to create a new topic. This is usually called when someone initiates a P2P
* conversation. No need to call Subscribe on this topic.
*
* @param topicName name of the new topic to create
* @return newly created topic or null
*/
public abstract Topic<?> onNewTopic(String topicName);
/**
* Handle server message. Default handler calls {@code #dispatchPacket(...)} on a
* websocket thread.
* A subclassed listener may wish to call {@code dispatchPacket()} on a UI thread
*
* @param msg message to be processed
* @return true if no further processing is needed, for instance if you called
* {@link #dispatchPacket(ServerMessage)}, false if Connection should process the message.
* @see #dispatchPacket(com.tinode.streaming.model.ServerMessage)
*/
public boolean onMessage(ServerMessage<?> msg) {
return false;
}
}
/**
* TODO(gene): implement autoreconnect with exponential backoff
*/
class ExpBackoff {
private int mRetryCount = 0;
final private long SLEEP_TIME_MILLIS = 500; // 500 ms
final private long MAX_DELAY = 1800000; // 30 min
private Random random = new Random();
void reset() {
mRetryCount = 0;
}
/**
*
* @return
*/
long getSleepTimeMillis() {
int attempt = mRetryCount;
return Math.min(SLEEP_TIME_MILLIS * (random.nextInt(1 << attempt) + (1 << (attempt+1))),
MAX_DELAY);
}
}
}

View File

@ -0,0 +1,105 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MeTopic.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.streaming;
import com.fasterxml.jackson.databind.JavaType;
import com.tinode.streaming.model.ClientMessage;
import com.tinode.streaming.model.MsgClientPub;
/**
* Topic for announcing one's presence and exchanging P2P messages
*/
public class MeTopic<T> extends Topic<T> {
protected MeTopic(final Connection conn, String party, JavaType typeOfT, final MeListener<T> l) {
super(conn, party, typeOfT, new Listener<T>() {
@Override
public void onSubscribe(int code, String text) {
l.onSubscribe(code, text);
}
@Override
public void onUnsubscribe(int code, String text) {
l.onUnsubscribe(code, text);
}
@Override
public void onPublish(String topic, int code, String text) {
l.onPublish(topic, code, text);
}
@Override
public void onData(String from, T content) {
l.onData(!from.equals(conn.getMyUID()), content);
}
});
}
public MeTopic(Connection conn, Class<?> typeOfT, MeListener<T> l) {
this(conn, Connection.TOPIC_ME, conn.getTypeFactory().constructType(typeOfT), l);
}
/**
* Send a message to a specific user without creating a topic dedicated to the conversation.
* Normally one should create a dedicated topic for a p2p chat
*
* @param to user ID to send message to
* @param content message payload
* @throws NotConnectedException
*/
public void Publish(String to, T content) throws NotConnectedException {
ClientMessage msg = new ClientMessage();
String topic = Connection.TOPIC_P2P + to;
msg.pub = new MsgClientPub<T>(topic, content);
String id = mConnection.sendPacket(msg);
mReplyExpected.put(id, PACKET_TYPE_PUB);
}
public interface MeListener<T> {
public void onSubscribe(int code, String text);
public void onUnsubscribe(int code, String text);
public void onPublish(String party, int code, String text);
public void onData(boolean isReply, T content);
}
public static String topicNameForContact(String contactId) {
return Connection.TOPIC_P2P + contactId;
}
public MeTopic<T> startP2P(String party, MeListener<T> l) {
if (!party.startsWith(Connection.TOPIC_P2P)) {
party = topicNameForContact(party);
}
MeTopic<T> topic = new MeTopic<T>(mConnection, party, mTypeOfData, l);
mConnection.registerP2PTopic(topic);
return topic;
}
public <U> MeTopic<U> startP2P(String party, Class<?> typeOfU, MeListener<U> l) {
if (!party.startsWith(Connection.TOPIC_P2P)) {
party = Connection.TOPIC_P2P + party;
}
MeTopic<U> topic = new MeTopic<U>(mConnection, party, mConnection.getTypeFactory().constructType(typeOfU), l);
mConnection.registerP2PTopic(topic);
return topic;
}
}

View File

@ -0,0 +1,49 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : NotConnectedException.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.streaming;
import java.io.IOException;
/**
* Created by gene on 2/25/14.
*/
public final class NotConnectedException extends IOException {
public NotConnectedException(String msg) {
super(msg);
}
public NotConnectedException(String msg, Throwable cause) {
super(msg, cause);
}
/**
* Creates exception with the specified cause. Consider using
* {@link #NotConnectedException(String, Throwable)} instead if you can
* describe what actually happened.
*
* @param cause root exception that caused this exception to be thrown.
*/
public NotConnectedException(Throwable cause) {
super(cause);
}
}

View File

@ -0,0 +1,81 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : PresTopic.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.streaming;
import android.util.Log;
import com.fasterxml.jackson.databind.type.TypeFactory;
import java.util.ArrayList;
import java.util.Set;
/**
* Topic for receiving presence notifications
*/
public class PresTopic<T> extends Topic {
private static final String TAG = "com.tinode.PresTopic";
public PresTopic(Connection conn, Class<?> typeOfT, final PresListener<T> l) {
super(conn, Connection.TOPIC_PRES, conn.getTypeFactory().
constructCollectionType(ArrayList.class, conn.getTypeFactory()
.constructParametricType(PresenceUpdate.class, typeOfT)),
new Listener<ArrayList<PresenceUpdate<T>>>() {
@Override
public void onSubscribe(int code, String text) {
l.onSubscribe(code, text);
}
@Override
public void onUnsubscribe(int code, String text) {
l.onUnsubscribe(code, text);
}
@Override
public void onPublish(String topic, int code, String text) {
}
@Override
public void onData(String from, ArrayList<PresenceUpdate<T>> content) {
for (PresenceUpdate<T> upd : content) {
l.onData(upd.who, upd.online, upd.status);
}
}
}
);
}
static class PresenceUpdate<T> {
public String who;
public Boolean online;
public T status;
public PresenceUpdate() {
}
}
public interface PresListener<T> {
public void onSubscribe(int code, String text);
public void onUnsubscribe(int code, String text);
public void onData(String who, Boolean online, T status);
}
}

View File

@ -0,0 +1,222 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : Topic.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************/
package com.tinode.streaming;
import android.support.v4.util.SimpleArrayMap;
import android.util.Log;
import com.fasterxml.jackson.databind.JavaType;
import com.tinode.streaming.model.ServerMessage;
/**
*
* Class for handling communication on a single topic
*
*/
public class Topic<T> {
private static final String TAG = "com.tinode.Topic";
protected static final int STATUS_SUBSCRIBED = 2;
protected static final int STATUS_PENDING = 1;
protected static final int STATUS_UNSUBSCRIBED = 0;
protected static final int PACKET_TYPE_PUB = 1;
protected static final int PACKET_TYPE_SUB = 2;
protected static final int PACKET_TYPE_UNSUB = 3;
protected JavaType mTypeOfData;
protected String mName;
protected Connection mConnection;
protected int mStatus;
// Outstanding requests, key = request id
protected SimpleArrayMap<String, Integer> mReplyExpected;
private Listener<T> mListener;
public Topic(Connection conn, String name, JavaType typeOfT, Listener<T> l) {
mTypeOfData = typeOfT;
mConnection = conn;
mName = name;
mListener = l;
mStatus = STATUS_UNSUBSCRIBED;
mReplyExpected = new SimpleArrayMap<String,Integer>();
}
public Topic(Connection conn, String name, Class<?> typeOfT, Listener<T> l) {
this(conn, name, conn.getTypeFactory().constructType(typeOfT), l);
}
/**
* Construct a topic for a group chat. Use this constructor if payload is non-trivial, such as
* collection or a generic class. If content is trivial (POJO), use constructor which takes
* Class&lt;?&gt; as a typeOfT parameter.
*
* Construct {@code }typeOfT} with one of {@code
* com.fasterxml.jackson.databind.type.TypeFactory.constructXYZ()} methods such as
* {@code mMyConnectionInstance.getTypeFactory().constructType(MyPayloadClass.class)} or see
* source of {@link com.tinode.streaming.PresTopic} constructor.
*
* The actual topic name will be set after completion of a successful Subscribe call
*
* @param conn connection
* @param typeOfT type of content
* @param l event listener
*/
public Topic(Connection conn, JavaType typeOfT, Listener<T> l) {
this(conn, Connection.TOPIC_NEW, typeOfT, l);
}
/**
* Create topic for a new group chat. Use this constructor if payload is trivial (POJO)
* Topic will not be usable until Subscribe is called
*
*/
public Topic(Connection conn, Class<?> typeOfT, Listener<T> l) {
this(conn, conn.getTypeFactory().constructType(typeOfT), l);
}
/**
* Subscribe topic
*
* @throws NotConnectedException
*/
public void Subscribe() throws NotConnectedException {
if (mStatus == STATUS_UNSUBSCRIBED) {
String id = mConnection.subscribe(this);
if (id != null) {
mReplyExpected.put(id, PACKET_TYPE_SUB);
}
mStatus = STATUS_PENDING;
}
}
/**
* Unsubscribe topic
*
* @throws NotConnectedException
*/
public void Unsubscribe() throws NotConnectedException {
if (mStatus == STATUS_SUBSCRIBED) {
String id = mConnection.unsubscribe(this);
if (id != null) {
mReplyExpected.put(id, PACKET_TYPE_UNSUB);
}
}
}
/**
* Publish message to a topic. It will attempt to publish regardless of subscription status.
*
* @param content payload
* @throws NotConnectedException
*/
public void Publish(T content) throws NotConnectedException {
String id = mConnection.publish(this, content);
if (id != null) {
mReplyExpected.put(id, PACKET_TYPE_PUB);
}
}
public JavaType getDataType() {
return mTypeOfData;
}
public String getName() {
return mName;
}
protected void setName(String name) {
mName = name;
}
public Listener<T> getListener() {
return mListener;
}
protected void setListener(Listener<T> l) {
mListener = l;
}
public int getStatus() {
return mStatus;
}
public boolean isSubscribed() {
return mStatus == STATUS_SUBSCRIBED;
}
protected void setStatus(int status) {
mStatus = status;
}
protected void disconnected() {
mReplyExpected.clear();
if (mStatus != STATUS_UNSUBSCRIBED) {
mStatus = STATUS_UNSUBSCRIBED;
if (mListener != null) {
mListener.onUnsubscribe(503, "connection lost");
}
}
}
@SuppressWarnings("unchecked")
protected boolean dispatch(ServerMessage<?> pkt) {
Log.d(TAG, "Topic " + getName() + " dispatching");
ServerMessage<T> msg = null;
try {
// This generates the "unchecked" warning
msg = (ServerMessage<T>)pkt;
} catch (ClassCastException e) {
Log.i(TAG, "Invalid type of content in Topic [" + getName() + "]");
return false;
}
if (mListener != null) {
if (msg.data != null) { // Incoming data packet
mListener.onData(msg.data.origin, msg.data.content);
} else if (msg.ctrl != null && msg.ctrl.id != null) {
Integer type = mReplyExpected.get(msg.ctrl.id);
if (type == PACKET_TYPE_SUB) {
if (msg.ctrl.code >= 200 && msg.ctrl.code < 300) {
mStatus = STATUS_SUBSCRIBED;
} else if (mStatus == STATUS_PENDING) {
mStatus = STATUS_UNSUBSCRIBED;
}
mListener.onSubscribe(msg.ctrl.code, msg.ctrl.text);
} else if (type == PACKET_TYPE_UNSUB) {
mStatus = STATUS_UNSUBSCRIBED;
mListener.onUnsubscribe(msg.ctrl.code, msg.ctrl.text);
} else if (type == PACKET_TYPE_PUB) {
mListener.onPublish(msg.ctrl.topic, msg.ctrl.code, msg.ctrl.text);
} else {
return false;
}
}
return true;
}
return false;
}
public interface Listener<T> {
public void onSubscribe(int code, String text);
public void onUnsubscribe(int code, String text);
public void onPublish(String topicName, int code, String text);
public void onData(String from, T content);
}
}

View File

@ -0,0 +1,49 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : ClientMessage.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Convenience wrapper for Client to Server messages; publish of type T
*
*****************************************************************************/
package com.tinode.streaming.model;
public class ClientMessage<T> {
public MsgClientLogin login;
public MsgClientPub<T> pub;
public MsgClientSub sub;
public MsgClientUnsub unsub;
@Override
public String toString() {
if (login != null) {
return "login:{" + login.toString() + "}";
} else if (pub != null) {
return "pub:{" + pub.toString() + "}";
} else if (sub != null) {
return "sub:{" + sub.toString() + "}";
} else if (unsub != null) {
return "unsub:{" + unsub.toString() + "}";
}
return "null";
}
}

View File

@ -0,0 +1,79 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgClientHeader.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Generic header of client to server messages
*
*****************************************************************************/
package com.tinode.streaming.model;
import java.util.Date;
import java.util.Map;
class MsgClientHeader {
public String id;
public String topic;
public Map<String, Object> params;
MsgClientHeader(String topic) {
this.topic = topic;
}
public void addParam(String key, Object val) {
params.put(key, val);
}
public boolean getBoolParam(String key) {
return Utils.getBoolParam(params, key);
}
public String getStringParam(String key) {
return Utils.getStringParam(params, key);
}
public Date getDateParam(String key) {
return Utils.getDateParam(params, key);
}
public int getIntParam(String key) {
return Utils.getIntParam(params, key);
}
public double getDoubleParam(String key) {
return Utils.getDoubleParam(params, key);
}
public void setId(String id) {
this.id = id;
}
public String getId() { return id; }
public void setTopic(String topic) {
this.topic = topic;
}
public String getTopic() {
return topic;
}
}

View File

@ -0,0 +1,79 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgClientLogin.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Client login packet
*
*****************************************************************************/
package com.tinode.streaming.model;
public class MsgClientLogin {
public static final String LOGIN_BASIC = "basic";
public static final String LOGIN_TOKEN = "token";
public String id;
public String scheme; // "basic" or "token"
public String secret; // <uname + ":" + password> or <HMAC-signed token>
public String expireIn; // String "5000s" for 5000 seconds or "128h26m" for 128 hours 26 min
public MsgClientLogin() {
expireIn = "24h";
}
public MsgClientLogin(String scheme, String secret, String expireIn) {
this.scheme = scheme;
this.secret = secret;
this.expireIn = expireIn;
}
public MsgClientLogin(String scheme, String secret) {
this(scheme, secret, "24h");
}
public void setId(String id) {
this.id = id;
}
public void setExpireIn(String expireIn) {
this.expireIn = expireIn;
}
public static String makeToken(String uname, String password) {
return uname + ":" + password;
}
public void Login(String scheme, String secret) {
this.scheme = scheme;
this.secret = secret;
}
public void basicLogin(String uname, String password) {
Login(LOGIN_BASIC, makeToken(uname, password));
}
public void tokenLogin(String token) {
Login(LOGIN_TOKEN, token);
}
}

View File

@ -0,0 +1,38 @@
package com.tinode.streaming.model;
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgClientPub.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Client pub(lish) packet with generic payload <T>
*
*****************************************************************************/
public class MsgClientPub<T> extends MsgClientHeader {
public T content;
public MsgClientPub(String topic, T data) {
super(topic);
content = data;
}
}

View File

@ -0,0 +1,35 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgClientSub.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Client sub(scribe) packet
*
*****************************************************************************/
package com.tinode.streaming.model;
public class MsgClientSub extends MsgClientHeader {
public MsgClientSub(String topic) {
super(topic);
}
}

View File

@ -0,0 +1,34 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgClientUnsub.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Client unsub(scribe) packet.
*
*****************************************************************************/
package com.tinode.streaming.model;
public class MsgClientUnsub extends MsgClientHeader {
public MsgClientUnsub(String topic) {
super(topic);
}
}

View File

@ -0,0 +1,90 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgServerCtrl.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Server to client control message
*
*****************************************************************************/
package com.tinode.streaming.model;
import java.util.Date;
import java.util.Map;
public class MsgServerCtrl {
public String id;
public int code;
public String text;
public String topic;
public Map<String, Object> params;
@Override
public String toString() {
StringBuffer buff = new StringBuffer();
if (id != null && !id.isEmpty()) {
buff.append("(id:").append(id).append(")");
}
buff.append(code).append(" ").append(text);
if (params != null && params.size() > 0) {
buff.append(" [");
String sep = "";
for (Map.Entry<String, Object> p : params.entrySet()) {
buff.append(sep);
sep = ",";
buff.append(p.getKey()).append(":");
StringBuffer val = new StringBuffer(p.getValue().toString());
if (val.length() > 6) {
buff.append(val, 0, 5).append("...");
} else {
buff.append(val);
}
}
buff.append("]");
}
return buff.toString();
}
public boolean getBoolParam(String key) {
return Utils.getBoolParam(params, key);
}
public String getStringParam(String key) {
return Utils.getStringParam(params, key);
}
public Date getDateParam(String key) {
return Utils.getDateParam(params, key);
}
public int getIntParam(String key) {
return Utils.getIntParam(params, key);
}
public double getDoubleParam(String key) {
return Utils.getDoubleParam(params, key);
}
public Map<String, Object> getParams() {
return params;
}
}

View File

@ -0,0 +1,51 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MsgServerData.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Server to client data packet with generic payload <T>
*
*****************************************************************************/
package com.tinode.streaming.model;
public class MsgServerData<T> {
public String id;
public String topic;
public String origin;
public T content;
public String toString() {
StringBuilder buff = new StringBuilder();
buff.append("{topic:").append(topic).append(",");
if (origin != null && origin.length()>0) {
buff.append("from:")
.append(origin)
.append(",");
}
buff.append(content.getClass().getName())
.append(":")
.append(content.toString())
.append("}");
return buff.toString();
}
}

View File

@ -0,0 +1,73 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : ServerMessage.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Convenience wrapper for Server to Client messages, data messages are of
* type T
*
*****************************************************************************/
package com.tinode.streaming.model;
public class ServerMessage<T> {
public MsgServerCtrl ctrl;
public MsgServerData<T> data;
public ServerMessage() {
}
public ServerMessage(MsgServerCtrl ctrl) {
this.ctrl = ctrl;
}
public ServerMessage(MsgServerData<T> data) {
this.data = data;
}
public String getTopic() {
if (ctrl != null) {
return ctrl.topic;
} else if (data != null) {
return data.topic;
}
return null;
}
public String getId() {
if (ctrl != null) {
return ctrl.id;
} else if (data != null) {
return data.id;
}
return null;
}
@Override
public String toString() {
if (ctrl != null) {
return "ctrl:{" + ctrl.toString() + "}";
} else if (data != null) {
return "pub:{" + data.toString() + "}";
}
return "null";
}
}

View File

@ -0,0 +1,111 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : Utils.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Utility class. Currently supports setters/getters of message parameters.
*
*****************************************************************************/
package com.tinode.streaming.model;
import android.util.Log;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
public class Utils {
final private static String TAG = "com.tinode.streaming.model.Utils";
public static boolean getBoolParam(Map<String, Object> params, String key) {
if (params.containsKey(key)) {
Object val = params.get(key);
if (val instanceof Boolean) {
return (Boolean) val;
} else if (val instanceof Integer) {
return ((Integer) val != 0);
}
}
return false;
}
public static String getStringParam(Map<String, Object> params, String key) {
if (params.containsKey(key)) {
Object val = params.get(key);
if (val instanceof String) {
return (String) val;
}
}
return null;
}
public static Date getDateParam(Map<String, Object> params, String key) {
if (params.containsKey(key)) {
Object val = params.get(key);
if (val instanceof Date) {
return (Date) val;
} else if (val instanceof String) {
String str = (String) val;
Date d = null;
if (str.endsWith("Z")) {
// Parsing time in RFC3339 format in UTC time zone with no fraction
try {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
d = sdf.parse(str);
}
catch (java.text.ParseException e) {
Log.d(TAG, "Invalid or unrecognized date format");
}
return d;
}
}
}
return null;
}
public static int getIntParam(Map<String, Object> params, String key) {
if (params.containsKey(key)) {
Object val = params.get(key);
if (val instanceof Integer) {
return (Integer) val;
} else if (val instanceof Double) {
return ((Double) val).intValue();
}
}
return 0;
}
public static double getDoubleParam(Map<String, Object> params, String key) {
if (params.containsKey(key)) {
Object val = params.get(key);
if (val instanceof Double) {
return (Double) val;
} else if (val instanceof Integer) {
return ((Integer) val).doubleValue();
}
}
return 0.0;
}
}

View File

@ -0,0 +1,4 @@
.gradle
/local.properties
/.idea/workspace.xml
.DS_Store

View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<module external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$/.." external.system.id="GRADLE" external.system.module.group="TinodeChatDemo" external.system.module.version="unspecified" type="JAVA_MODULE" version="4">
<component name="FacetManager">
<facet type="android" name="Android">
<configuration>
<option name="SELECTED_BUILD_VARIANT" value="debug" />
<option name="ASSEMBLE_TASK_NAME" value="assembleDebug" />
<option name="COMPILE_JAVA_TASK_NAME" value="compileDebugJava" />
<option name="ASSEMBLE_TEST_TASK_NAME" value="assembleDebugTest" />
<option name="SOURCE_GEN_TASK_NAME" value="generateDebugSources" />
<option name="ALLOW_USER_CONFIGURATION" value="false" />
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/src/main/AndroidManifest.xml" />
<option name="RES_FOLDER_RELATIVE_PATH" value="/src/main/res" />
<option name="RES_FOLDERS_RELATIVE_PATH" value="file://$MODULE_DIR$/src/main/res" />
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/src/main/assets" />
</configuration>
</facet>
<facet type="android-gradle" name="Android-Gradle">
<configuration>
<option name="GRADLE_PROJECT_PATH" value=":app" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager" inherit-compiler-output="false">
<output url="file://$MODULE_DIR$/build/classes/debug" />
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/build/source/r/debug" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/source/aidl/debug" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/source/buildConfig/debug" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/source/rs/debug" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/res/rs/debug" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/build/source/r/test/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/source/aidl/test/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/source/buildConfig/test/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/source/rs/test/debug" isTestSource="true" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/build/res/rs/test/debug" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/res" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/aidl" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/assets" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/java" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/jni" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/debug/rs" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/main/res" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/main/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/main/aidl" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/main/assets" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/main/java" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/main/jni" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/main/rs" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/res" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/resources" type="java-test-resource" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/aidl" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/assets" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/java" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/jni" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/src/androidTest/rs" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/build/apk" />
<excludeFolder url="file://$MODULE_DIR$/build/assets" />
<excludeFolder url="file://$MODULE_DIR$/build/bundles" />
<excludeFolder url="file://$MODULE_DIR$/build/classes" />
<excludeFolder url="file://$MODULE_DIR$/build/dependency-cache" />
<excludeFolder url="file://$MODULE_DIR$/build/incremental" />
<excludeFolder url="file://$MODULE_DIR$/build/libs" />
<excludeFolder url="file://$MODULE_DIR$/build/manifests" />
<excludeFolder url="file://$MODULE_DIR$/build/res" />
<excludeFolder url="file://$MODULE_DIR$/build/symbols" />
<excludeFolder url="file://$MODULE_DIR$/build/tmp" />
</content>
<orderEntry type="jdk" jdkName="Android API 19 Platform" jdkType="Android SDK" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" exported="" name="appcompat-v7-19.0.1" level="project" />
<orderEntry type="library" exported="" name="jackson-databind-2.3.2" level="project" />
<orderEntry type="library" exported="" name="jackson-annotations-2.3.0" level="project" />
<orderEntry type="library" exported="" name="support-v4-19.0.1" level="project" />
<orderEntry type="library" exported="" name="jackson-core-2.3.2" level="project" />
</component>
</module>

View File

@ -0,0 +1,34 @@
apply plugin: 'android'
android {
compileSdkVersion 19
buildToolsVersion '19.0.1'
defaultConfig {
minSdkVersion 8
targetSdkVersion 19
versionCode 1
versionName "1.0"
}
buildTypes {
release {
runProguard false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
}
}
packagingOptions {
exclude 'META-INF/ASL2.0'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/LICENSE.txt'
exclude 'META-INF/NOTICE.txt'
exclude 'META-INF/notice.txt'
exclude 'META-INF/license.txt'
}
}
dependencies {
compile 'com.android.support:appcompat-v7:+'
compile 'com.fasterxml.jackson.core:jackson-core:2.3.2'
compile 'com.fasterxml.jackson.core:jackson-databind:2.3.2'
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.tinode.chatdemo" >
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme" >
<activity
android:name="com.tinode.example.chatdemo.MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/app_name" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,675 @@
/*****************************************************************************
*
* Copyright 2014, Tinode, All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* File : MainActivity.java
* Author : Gene Sokolov
* Created : 18-May-2014
*
*****************************************************************************
*
* Description :
*
* Crete the main app activity
*
*****************************************************************************/
package com.tinode.example.chatdemo;
import android.app.ProgressDialog;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.support.v4.app.FragmentTransaction;
import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.app.ActionBarActivity;
import android.text.format.Time;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import com.tinode.Tinode;
import com.tinode.rest.Request;
import com.tinode.rest.Rest;
import com.tinode.rest.model.Contact;
import com.tinode.streaming.model.Utils;
import com.tinode.streaming.Connection;
import com.tinode.streaming.MeTopic;
import com.tinode.streaming.NotConnectedException;
import com.tinode.streaming.PresTopic;
import com.tinode.streaming.Topic;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Vector;
public class MainActivity extends ActionBarActivity {
private static final String TAG = "com.tinode.example.ChatDemo";
// Generate your own API key
private static final String APIKEY = "--GENERATE YOUR OWN KEY--";
private static final String STATE_SELECTED_NAVIGATION_ITEM = "selected_navigation_item";
private static final int FRAGMENT_IDX_LOGIN = 0;
private static final int FRAGMENT_IDX_LOG = 1;
private static final int FRAGMENT_IDX_CONTACTS = 2;
private MainFragmentPagerAdapter mPageAdapter;
private ActionBar mActionBar;
private ViewPager mViewPager;
private ActionBar.TabListener mTabListener;
List<Fragment> mFragments;
private Connection mChatConn;
private PresTopic<String> mPresence;
private MeTopic<String> mOnline;
private ArrayAdapter<String> mConnectionLog;
private ArrayAdapter<Contact> mContactList;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mActionBar = getSupportActionBar();
// Specify that tabs should be displayed in the action bar.
mActionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
mActionBar.setDisplayOptions(0, ActionBar.DISPLAY_SHOW_TITLE);
mFragments = new Vector<Fragment>();
mFragments.add(new LoginFragment());
mFragments.add(new LogFragment());
mFragments.add(new ContactsFragment());
mPageAdapter = new MainFragmentPagerAdapter(getSupportFragmentManager(), mFragments);
mViewPager = (ViewPager) findViewById(R.id.pager);
mViewPager.setAdapter(mPageAdapter);
mViewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
mActionBar.setSelectedNavigationItem(position);
}
});
// Create a tab listener that is called when the user changes tabs.
mTabListener = new ActionBar.TabListener() {
@Override
public void onTabSelected(ActionBar.Tab tab, FragmentTransaction ft) {
mViewPager.setCurrentItem(tab.getPosition());
}
@Override
public void onTabUnselected(ActionBar.Tab tab, FragmentTransaction ft) {
}
@Override
public void onTabReselected(ActionBar.Tab tab, FragmentTransaction ft) {
}
};
mActionBar.addTab(mActionBar.newTab().setText("Start").setTabListener(mTabListener));
mActionBar.addTab(mActionBar.newTab().setText("Log").setTabListener(mTabListener));
mActionBar.addTab(mActionBar.newTab().setText("Contacts").setTabListener(mTabListener));
mConnectionLog = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1);
mContactList = new ArrayAdapter<Contact>(this, android.R.layout.simple_list_item_1);
}
@Override
public void onRestoreInstanceState(Bundle savedInstanceState) {
// Restore the previously serialized current tab position.
if (savedInstanceState.containsKey(STATE_SELECTED_NAVIGATION_ITEM)) {
getActionBar().setSelectedNavigationItem(savedInstanceState
.getInt(STATE_SELECTED_NAVIGATION_ITEM));
}
}
@Override
public void onSaveInstanceState(Bundle outState) {
// Serialize the current tab position.
outState.putInt(STATE_SELECTED_NAVIGATION_ITEM, getActionBar()
.getSelectedNavigationIndex());
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
getMenuInflater().inflate(R.menu.main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
int id = item.getItemId();
if (id == R.id.action_start_chat) {
Toast.makeText(this, "This button does nothing", Toast.LENGTH_SHORT).show();
return true;
}
return super.onOptionsItemSelected(item);
}
public void onConnectButton(View connect) {
Log.d(TAG, "Connect clicked");
String hostName = ((EditText) findViewById(R.id.edit_hostname))
.getText().toString();
Log.d(TAG, "Connect to host: " + hostName);
// TODO(gene): the initialization placed here just for debugging. In production initialization
// should happen in onCreate. This code should just call getInstance().
try {
Tinode.initialize(new URL(hostName), APIKEY);
} catch (MalformedURLException e) {
Toast.makeText(this, "Invalid URL", Toast.LENGTH_SHORT).show();
}
mChatConn = Connection.getInstance();
// Set a handler on the main thread to be used for calling EventListener methods.
mChatConn.setHandler(new Handler(Looper.getMainLooper()));
mChatConn.setListener(new Connection.EventListener() {
@Override
public void onConnect(int code, String reason, Map<String, Object> params) {
Log.d(TAG, "Connected");
postToLog(getString(R.string.connected) + ", vers: " + Utils.getStringParam(params, "protocol"));
LoginFragment lf = (LoginFragment) mFragments.get(FRAGMENT_IDX_LOGIN);
lf.setConnectionStatus(true);
}
@Override
public void onLogin(int code, String text) {
Log.d(TAG, "Login done: " + text + "(" + code +")");
postToLog(text + " (" + code + ")");
LoginFragment lf = (LoginFragment) mFragments.get(FRAGMENT_IDX_LOGIN);
lf.setLoginStatus(code == 200);
}
@Override
public Topic<?> onNewTopic(String topicName) {
postToLog("new topic " + topicName);
if (!topicName.startsWith(Connection.TOPIC_P2P)) {
Log.i(TAG, "Don't know how to create topic " + topicName);
return null;
}
return startNewChat(topicName);
}
@Override
public void onDisconnect(int code, String reason) {
Log.d(TAG, "Disconnected");
postToLog(getString(R.string.disconnected) + " " + reason + " (" + code + ")");
LoginFragment lf = (LoginFragment) mFragments.get(FRAGMENT_IDX_LOGIN);
lf.setConnectionStatus(false);
lf.setLoginStatus(false);
}
});
mChatConn.Connect(false);
Toast.makeText(this, "Connecting...", Toast.LENGTH_SHORT).show();
}
public void onLoginButton(View login) {
Log.d(TAG, "Login clicked");
if (mChatConn == null) {
Toast.makeText(this, "Not connected", Toast.LENGTH_SHORT).show();
return;
}
String username = ((EditText) findViewById(R.id.edit_username))
.getText().toString();
String password = ((EditText) findViewById(R.id.edit_password))
.getText().toString();
try {
mChatConn.Login(username, password);
} catch (NotConnectedException e) {
Toast.makeText(this, "Not connected", Toast.LENGTH_SHORT).show();
Log.d(TAG, e.toString());
}
}
public void onPublishButton(View send) {
int tab = mActionBar.getSelectedNavigationIndex();
ChatFragment chatUi = (ChatFragment) mFragments.get(tab);
String text = chatUi.getMessage();
Log.d(TAG, "onPublish(" + chatUi.getTopicName() + ", " + text + ")");
try {
chatUi.getTopic().Publish(text);
} catch (NotConnectedException e) {
Toast.makeText(this, "Not connected", Toast.LENGTH_SHORT).show();
Log.d(TAG, e.toString());
}
}
public void onStartTopicButton(View go) {
if (mChatConn != null && mChatConn.isAuthenticated()) {
int tab = mActionBar.getSelectedNavigationIndex();
ContactsFragment contacts = (ContactsFragment) mFragments.get(tab);
String name = contacts.getTopicName().trim();
if (name.length() > 0) {
startNewChat(name);
}
} else {
Toast.makeText(this, "Must login first", Toast.LENGTH_SHORT);
}
}
private void setCheckBox(int id, boolean state) {
CheckBox cb = ((CheckBox) findViewById(id));
if (cb != null) {
cb.setChecked(state);
}
}
private Topic<?> startNewChat(final String topicName) {
final ChatFragment chatUi = new ChatFragment();
Topic<String> topic = null;
if (topicName.startsWith(Connection.TOPIC_P2P)) {
topic = mOnline.startP2P(topicName, new MeTopic.MeListener<String>() {
ChatFragment mChatUi = chatUi;
@Override
public void onSubscribe(int code, String text) {
}
@Override
public void onUnsubscribe(int code, String text) {
mChatUi.logMessage("SYS", "unsubscribed");
postToLog(topicName + " unsubscribed");
}
@Override
public void onPublish(String party, int code, String text) {
mChatUi.clearSendField();
postToLog(topicName + " posted (" + code + ") " + text);
}
@Override
public void onData(boolean isReply, String content) {
String from = isReply ? topicName : "me";
mChatUi.logMessage(from, content);
postToLog(from.substring(0, 5) + "...:" + content);
}
});
} else {
topic = new Topic<String>(mChatConn, topicName, String.class, new Topic.Listener<String>() {
ChatFragment mChatUi = chatUi;
@Override
public void onSubscribe(int code, String text) {
// This won't be called, no need to implement
}
@Override
public void onUnsubscribe(int code, String text) {
mChatUi.logMessage("SYS", "unsubscribed");
postToLog(topicName + " unsubscribed");
}
@Override
public void onPublish(String topicName, int code, String text) {
mChatUi.clearSendField();
postToLog(topicName + " posted (" + code + ") " + text);
}
@Override
public void onData(String from, String content) {
mChatUi.logMessage(from, content);
postToLog(from.substring(0, 5) + "...:" + content);
}
});
}
chatUi.setTopic(topic);
mFragments.add(chatUi);
mPageAdapter.notifyDataSetChanged();
mActionBar.addTab(mActionBar.newTab().setText("Chat").setTabListener(mTabListener), true);
Log.d(TAG, "New topic created [" + topicName + "]");
return topic;
}
public void onSubscribeMeCheckbox(View subsme) {
Log.d(TAG, "[Subscribe !me] clicked");
if (mChatConn == null) {
Toast.makeText(this, "Not connected", Toast.LENGTH_SHORT).show();
((CheckBox) subsme).setChecked(false);
return;
}
if (mOnline == null) {
mOnline = new MeTopic<String>(mChatConn, String.class, new MeTopic.MeListener<String>() {
@Override
public void onSubscribe(final int code, final String text) {
Log.d(TAG, "me.onSubscribe (" + code +") " + text);
setCheckBox(R.id.subscribe_me, mOnline.isSubscribed());
postToLog("!me subscribed");
}
@Override
public void onUnsubscribe(final int code, final String text) {
Log.d(TAG, "me.onUnsubscribe (" + code +") " + text);
setCheckBox(R.id.subscribe_me, mOnline.isSubscribed());
postToLog("!me unsubscribed");
}
@Override
public void onPublish(String party, int code, String text) {
Log.d(TAG, "me.onPublish (" + code + ") " + text);
postToLog("" + code + " " + text);
}
@Override
public void onData(boolean isReply, final String content) {
Log.d(TAG, "me.onData(" + isReply + ", '" + content + "') " + content);
postToLog("!me: " + content);
Toast.makeText(MainActivity.this, "Message received: " + content,
Toast.LENGTH_SHORT).show();
}
});
}
try {
Toast.makeText(this, "Requesting !me...", Toast.LENGTH_SHORT).show();
if (!((CheckBox) subsme).isChecked()) {
mOnline.Unsubscribe();
} else {
mOnline.Subscribe();
}
} catch (NotConnectedException e) {
Log.d(TAG, e.toString());
}
((CheckBox) subsme).setChecked(false);
}
public void onSubscribePresCheckbox(View subspres) {
Log.d(TAG, "[Subscribe !pres] clicked");
if (mChatConn == null) {
Toast.makeText(this, "Not connected", Toast.LENGTH_SHORT).show();
((CheckBox) subspres).setChecked(false);
return;
}
if (mPresence == null) {
mPresence = new PresTopic<String>(mChatConn, String.class,
new PresTopic.PresListener<String>() {
@Override
public void onSubscribe(int code, String text) {
Log.d(TAG, "pres.onSubscribe (" + code +") " + text);
setCheckBox(R.id.subscribe_pres, mPresence.isSubscribed());
postToLog("!pres subscribed");
}
@Override
public void onUnsubscribe(int code, String text) {
Log.d(TAG, "pres.onSubscribe (" + code +") " + text);
setCheckBox(R.id.subscribe_pres, mPresence.isSubscribed());
postToLog("!pres unsubscribed");
}
@Override
public void onData(String who, Boolean online, String status) {
String strOnline = (online ? "ONL" : "OFFL");
Log.d(TAG, who + " is " + strOnline
+ " with status: " + (status == null ? "null" : "'" + status + "'"));
postToLog(who.substring(0, 5) + "... is " + strOnline + "(" + status + ")");
}
});
}
try {
Toast.makeText(this, "Requesting !pres...", Toast.LENGTH_SHORT).show();
if (!((CheckBox) subspres).isChecked()) {
mPresence.Unsubscribe();
} else {
mPresence.Subscribe();
}
} catch (NotConnectedException e) {
Log.d(TAG, e.toString());
}
((CheckBox) subspres).setChecked(false);
}
public void postToLog(String msg) {
Time now = new Time();
now.setToNow();
mConnectionLog.insert(now.format("%H:%M:%S") + " " + msg, 0);
}
public class MainFragmentPagerAdapter extends FragmentPagerAdapter {
private List<Fragment> mFragments;
public MainFragmentPagerAdapter(FragmentManager fm, List<Fragment> fragments) {
super(fm);
mFragments = fragments;
}
@Override
public int getCount() {
return mFragments.size();
}
@Override
public Fragment getItem(int position) {
return mFragments.get(position);
}
}
/**
* Chat UI.
*/
public class ChatFragment extends Fragment {
private Topic<String> mTopic;
private View mChatView;
public ChatFragment() {
}
public void setTopic(Topic<String> topic) {
mTopic = topic;
}
public Topic<String> getTopic() {
return mTopic;
}
public String getTopicName() {
return mTopic.getName();
}
public String getMessage() {
return ((EditText) mChatView.findViewById(R.id.editChatMessage)).getText().toString();
}
public void clearSendField() {
((EditText) mChatView.findViewById(R.id.editChatMessage)).setText("");
}
public void logMessage(String who, String text) {
TextView log = (TextView) findViewById(R.id.chatLog);
CharSequence msg;
if (who.equals(mChatConn.getMyUID())) {
msg = "me: ";
} else if (who.length() > 3) {
int len = who.length();
msg = who.subSequence(len-2, len-1) + ": ";
} else {
msg = who + ": ";
}
log.append(msg + text + "\n");
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
mChatView = inflater.inflate(R.layout.fragment_chat, container, false);
((TextView) mChatView.findViewById(R.id.topic_name)).setText(mTopic.getName());
setHasOptionsMenu(true);
return mChatView;
}
}
/**
* Login fragment.
*/
public class LoginFragment extends Fragment {
private View mLoginView;
public LoginFragment() {
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
mLoginView = inflater.inflate(R.layout.fragment_login, container, false);
if (mOnline != null && mOnline.isSubscribed()) {
((CheckBox) mLoginView.findViewById(R.id.subscribe_me)).setChecked(true);
}
if (mPresence != null && mPresence.isSubscribed()) {
((CheckBox) mLoginView.findViewById(R.id.subscribe_me)).setChecked(true);
}
setConnectionStatus(mChatConn != null && mChatConn.isConnected());
setLoginStatus(mChatConn != null && mChatConn.isAuthenticated());
setHasOptionsMenu(true);
return mLoginView;
}
public void setConnectionStatus(boolean status) {
if (status) {
((TextView) mLoginView.findViewById(R.id.connection_status)).setText(R.string.connected);
} else {
((TextView) mLoginView.findViewById(R.id.connection_status)).setText(R.string.disconnected);
}
}
public void setLoginStatus(boolean status) {
if (status) {
((TextView) mLoginView.findViewById(R.id.login_status)).setText(R.string.logged_in);
} else {
((TextView) mLoginView.findViewById(R.id.login_status)).setText(R.string.not_authenticated);
}
}
}
/**
* Contacts/start conversation fragment.
*/
public class ContactsFragment extends Fragment {
private View mContactView;
public ContactsFragment() {
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
mContactView = inflater.inflate(R.layout.fragment_contacts, container, false);
final ListView contacts = ((ListView) mContactView.findViewById(R.id.contact_list));
contacts.setAdapter(mContactList);
contacts.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int pos, long id) {
Contact contact = (Contact) contacts.getItemAtPosition(pos);
((EditText) mContactView.findViewById(R.id.editTopicName)).setText(
MeTopic.topicNameForContact(contact.contactId));
}
});
if (mContactList.isEmpty() && mChatConn != null && mChatConn.isAuthenticated()) {
new AsyncTask<Void, Void, ArrayList<Contact>>() {
ProgressDialog dialog;
@Override
protected void onPreExecute() {
dialog = ProgressDialog.show(MainActivity.this, null,
"Fetching contacts...");
}
@Override
protected ArrayList<Contact> doInBackground(Void... ignored) {
Request req = Rest.buildRequest()
.setMethod("GET")
.setInDataType(Contact.class)
.addKind("contacts");
return Rest.executeBlocking(req);
}
@Override
protected void onPostExecute(ArrayList<Contact> result) {
if (result != null) {
mContactList.clear();
mContactList.addAll(result);
mContactList.notifyDataSetChanged();
}
dialog.dismiss();
}
}.execute();
}
if (mChatConn == null || !mChatConn.isAuthenticated()) {
Toast.makeText(MainActivity.this, "Must login first", Toast.LENGTH_SHORT).show();
}
setHasOptionsMenu(true);
return mContactView;
}
public String getTopicName() {
return ((EditText) mContactView.findViewById(R.id.editTopicName)).getText().toString();
}
}
/**
* Log fragment.
*/
public class LogFragment extends Fragment {
public LogFragment() {
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_log, container, false);
((ListView) rootView.findViewById(R.id.connection_log)).setAdapter(mConnectionLog);
setHasOptionsMenu(true);
return rootView;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,5 @@
<android.support.v4.view.ViewPager
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/pager"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -0,0 +1,71 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context="com.tinode.example.chatdemo.MainActivity$PlaceholderFragment">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/topicprompt"
android:id="@+id/textView"
android:layout_alignParentTop="true" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text=""
android:id="@+id/topic_name"
android:layout_alignRight="@+id/chatLog"
android:layout_alignEnd="@+id/chatLog"
android:layout_toRightOf="@+id/textView"
android:paddingLeft="5dp"
android:textIsSelectable="true"
android:singleLine="true"/>
<TextView
android:text=""
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:id="@+id/chatLog"
android:layout_above="@+id/editChatMessage"
android:layout_below="@+id/textView"
android:layout_margin="2dp"
android:padding="2dp" />
<EditText
android:id="@+id/editChatMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:inputType="textMultiLine"
android:scrollHorizontally="false"
android:lines="3"
android:minLines="3"
android:gravity="top|left"
android:layout_toLeftOf="@+id/chatSendButton" />
<Button
style="?android:attr/buttonStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/send"
android:id="@+id/chatSendButton"
android:layout_below="@+id/chatLog"
android:layout_alignRight="@+id/chatLog"
android:layout_alignEnd="@+id/chatLog"
android:onClick="onPublishButton"/>
</RelativeLayout>

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context="com.tinode.example.chatdemo.MainActivity$ContactsFragment">
<View
android:layout_height="2dip"
android:layout_width="fill_parent"
android:background="#ffcccccc"
android:id="@+id/view"
android:layout_marginBottom="8dp"
android:layout_below="@+id/textView2" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/create_or_join_topic"
android:id="@+id/textView0"
android:padding="4dp"
android:layout_alignParentTop="true"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/topicprompt"
android:id="@+id/textView"
android:layout_alignBottom="@+id/editTopicName"
android:padding="4dp"
android:gravity="center_vertical"
/>
<EditText
android:id="@+id/editTopicName"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_toLeftOf="@+id/startTopicButton"
android:layout_toRightOf="@+id/textView"
android:layout_above="@+id/textView2"
android:inputType="text"
android:hint="!new or topic name"
android:layout_below="@+id/textView0" />
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/go"
android:textAppearance="?android:attr/textAppearanceSmall"
android:layout_alignParentRight="true"
android:id="@+id/startTopicButton"
android:layout_below="@+id/textView0"
android:onClick="onStartTopicButton"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceMedium"
android:text="@string/pickuserprompt"
android:padding="4dp"
android:layout_below="@+id/startTopicButton"
android:id="@+id/textView2"/>
<ListView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/contact_list"
android:layout_centerHorizontal="true"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_below="@+id/view" />
</RelativeLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent">
<ListView
android:id="@+id/connection_log"
android:textAppearance="?android:attr/textAppearanceListItemSmall"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:headerDividersEnabled="false" />
</FrameLayout>

View File

@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin"
tools:context="com.tinode.example.chatdemo.MainActivity$LoginFragment">
<!-- 3 columns -->
<TableRow
android:id="@+id/tableRow1"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView1"
android:text="@string/hostprompt"
android:textAppearance="?android:attr/textAppearanceMedium"
android:layout_column="0" />
<EditText
android:id="@+id/edit_hostname"
android:text="@string/hostname"
android:layout_column="1"
android:layout_span="3"
android:layout_weight="1"
android:inputType="textUri" />
<Button
android:id="@+id/connect"
android:text="@string/connect"
android:layout_column="4"
android:textAppearance="?android:attr/textAppearanceSmall"
android:onClick="onConnectButton"/>
</TableRow>
<TableRow
android:id="@+id/tableRow2"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/connection_status"
android:text="@string/disconnected"
android:layout_column="1"
android:layout_span="4"
android:layout_weight="1" />
</TableRow>
<View
android:layout_height="2dip"
android:background="#ffcccccc" />
<TableRow
android:id="@+id/tableRow3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dip" >
<TextView
android:id="@+id/textView2"
android:text="@string/usernameprompt"
android:textAppearance="?android:attr/textAppearanceMedium"
android:layout_column="0" />
<EditText
android:id="@+id/edit_username"
android:layout_span="3"
android:layout_weight="1"
android:layout_column="1"
android:textAppearance="?android:attr/textAppearanceMedium"
android:hint="@string/username" />
<TextView
android:id="@+id/blankView"
android:text=""
android:textAppearance="?android:attr/textAppearanceMedium"
android:layout_column="4" />
</TableRow>
<!-- display this button in 3rd column via layout_column(zero based) -->
<TableRow
android:id="@+id/tableRow4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dip" >
<TextView
android:id="@+id/textView3"
android:text="@string/passwordprompt"
android:textAppearance="?android:attr/textAppearanceMedium"
android:layout_column="0" />
<EditText
android:id="@+id/edit_password"
android:text=""
android:layout_span="3"
android:layout_weight="1"
android:layout_column="1"
android:textAppearance="?android:attr/textAppearanceMedium"
android:password="true" />
<Button
android:id="@+id/login"
android:text="@string/login"
android:layout_column="4"
android:textAppearance="?android:attr/textAppearanceSmall"
android:onClick="onLoginButton"/>
</TableRow>
<TableRow
android:id="@+id/tableRow5"
android:layout_width="fill_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/login_status"
android:text="@string/not_authenticated"
android:layout_column="1"
android:layout_span="4"
android:layout_weight="1" />
</TableRow>
<CheckBox
android:id="@+id/subscribe_me"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|center_vertical"
android:text="@string/subscribemeprompt"
android:onClick="onSubscribeMeCheckbox"
android:textAppearance="@android:style/TextAppearance.Medium"/>
<CheckBox
android:id="@+id/subscribe_pres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="right|center_vertical"
android:textAppearance="@android:style/TextAppearance.Medium"
android:text="@string/subscribepresprompt"
android:onClick="onSubscribePresCheckbox" />
</TableLayout>

View File

@ -0,0 +1,11 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="com.tinode.example.chatdemo.MainActivity" >
<item android:id="@+id/action_start_chat"
android:title="@string/start_chat"
android:icon="@android:drawable/ic_menu_add"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,6 @@
<resources>
<!-- Example customization of dimensions originally defined in res/values/dimens.xml
(such as screen margins) for screens with more than 820dp of available width. This
would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
<dimen name="activity_horizontal_margin">64dp</dimen>
</resources>

View File

@ -0,0 +1,4 @@
<resources>
<dimen name="activity_horizontal_margin">8dp</dimen>
<dimen name="activity_vertical_margin">8dp</dimen>
</resources>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tinode Chat Demo</string>
<string name="hello_world">Hello world!</string>
<string name="action_settings">Settings</string>
<string name="connect">Connect</string>
<!-- ADB local host -->
<!--string name="hostname">http://10.0.2.2:8088/v0/</string-->
<!-- Genymotion local host address -->
<!--string name="hostname">http://10.0.3.2:8088/v0/</string-->
<!-- Live server address -->
<string name="hostname">http://use-your-own/v0/</string>
<string name="hostprompt">Host:</string>
<string name="usernameprompt">User name:</string>
<string name="passwordprompt">Password:</string>
<string name="login">Login</string>
<string name="username">user name</string>
<string name="topicprompt">Topic:</string>
<string name="disconnected">disconnected</string>
<string name="connected">connected</string>
<string name="logged_in">hurray! logged in</string>
<string name="not_authenticated">not authenticated</string>
<string name="subscribemeprompt">Announce my presence</string>
<string name="subscribepresprompt">Get presence notifications</string>
<string name="send">Send</string>
<string name="go">Go!</string>
<string name="pickuserprompt">Or pick user:</string>
<string name="start_chat">Start chat</string>
<string name="connect_login"><![CDATA[Connect & login]]></string>
<string name="create_or_join_topic">Create topic or join existing:</string>
</resources>

View File

@ -0,0 +1,8 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -0,0 +1,16 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:0.9.+'
}
}
allprojects {
repositories {
mavenCentral()
}
}

192
server/auth.go Normal file
View File

@ -0,0 +1,192 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : auth.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Authentication
*
*****************************************************************************/
package main
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"crypto/rand"
// "encoding/base32"
"encoding/base64"
"encoding/binary"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"log"
"time"
)
// 32 random bytes to be used for signing auth tokens
// FIXME(gene): move it to the database (make it unique per-application)
var hmac_salt = []byte{
0x4f, 0xbd, 0x77, 0xfe, 0xb6, 0x18, 0x81, 0x6e,
0xe0, 0xe2, 0x6d, 0xef, 0x1b, 0xac, 0xc6, 0x46,
0x1e, 0xfe, 0x14, 0xcd, 0x6d, 0xd1, 0x3f, 0x23,
0xd7, 0x79, 0x28, 0x5d, 0x27, 0x0e, 0x02, 0x3e}
// TODO(gene):change to use snowflake
// getRandomString generates 72 bits of randomness, returns 12 char-long random-looking string
func getRandomString() string {
buf := make([]byte, 9)
_, err := rand.Read(buf)
if err != nil {
panic("getRandomString: failed to generate a random string: " + err.Error())
}
//return base32.StdEncoding.EncodeToString(buf)
return base64.URLEncoding.EncodeToString(buf)
}
func genTopicName() string {
return "grp" + getRandomString()
}
// Generate HMAC hash from password
func passHash(password string) []byte {
hasher := hmac.New(md5.New, hmac_salt)
hasher.Write([]byte(password))
return hasher.Sum(nil)
}
func isValidPass(password string, validMac []byte) bool {
return hmac.Equal(validMac, passHash(password))
}
// Singned AppID. Composition:
// [1:algorithm version][4:appid][2:key sequence][1:isRoot][16:signature] = 24 bytes
// convertible to base64 without padding
// All integers are little-endian
const (
APIKEY_VERSION = 1
APIKEY_APPID = 4
APIKEY_SEQUENCE = 2
APIKEY_WHO = 1
APIKEY_SIGNATURE = 16
APIKEY_LENGTH = APIKEY_VERSION + APIKEY_APPID + APIKEY_SEQUENCE + APIKEY_WHO + APIKEY_SIGNATURE
)
// Client signature validation
// key: client's secret key
// Returns application id, key type
func checkApiKey(apikey string) (appid uint32, isRoot bool) {
if declen := base64.URLEncoding.DecodedLen(len(apikey)); declen != APIKEY_LENGTH {
return
}
data, err := base64.URLEncoding.DecodeString(apikey)
if err != nil {
log.Println("failed to decode.base64 appid ", err)
return
}
if data[0] != 1 {
log.Println("unknown appid signature algorithm ", data[0])
return
}
hasher := hmac.New(md5.New, hmac_salt)
hasher.Write(data[:APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO])
check := hasher.Sum(nil)
if !bytes.Equal(data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO:], check) {
log.Println("invalid apikey signature")
return
}
appid = binary.LittleEndian.Uint32(data[APIKEY_VERSION : APIKEY_VERSION+APIKEY_APPID])
isRoot = (data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE] == 1)
return
}
// TODO(gene): make UID variable length
// [2:type][8:UID][4:expires][16:signature] == 30 bytes
const (
SEC_TOKEN_TYPE = 2 //always zero for now
SEC_TOKEN_UID = 8
SEC_TOKEN_EXPIRES = 4
SEC_TOKEN_SIGNATURE = 16
SEC_TOKEN_LENGTH = SEC_TOKEN_TYPE + SEC_TOKEN_UID + SEC_TOKEN_EXPIRES + SEC_TOKEN_SIGNATURE
)
// Make a temporary token to be used instead of login/password
func makeSecurityToken(uid types.Uid, expires time.Time) string {
var buf = make([]byte, SEC_TOKEN_LENGTH)
b, _ := uid.MarshalBinary()
buf = append(buf[SEC_TOKEN_TYPE:], b...)
binary.LittleEndian.PutUint32(buf[SEC_TOKEN_TYPE+SEC_TOKEN_UID:], uint32(expires.Unix()))
hasher := hmac.New(md5.New, hmac_salt)
hasher.Write(buf[:SEC_TOKEN_TYPE+SEC_TOKEN_UID+SEC_TOKEN_EXPIRES])
return base64.URLEncoding.EncodeToString(hasher.Sum(buf[SEC_TOKEN_TYPE+SEC_TOKEN_UID+SEC_TOKEN_EXPIRES:]))
}
func checkSecurityToken(token string) (uid types.Uid, expires time.Time) {
if declen := base64.URLEncoding.DecodedLen(len(token)); declen != SEC_TOKEN_LENGTH {
return
}
data, err := base64.URLEncoding.DecodeString(token)
if err != nil {
log.Println("failed to decode.base64 sectoken ", err)
return
}
hasher := hmac.New(md5.New, hmac_salt)
hasher.Write(data[:SEC_TOKEN_TYPE+SEC_TOKEN_UID+SEC_TOKEN_EXPIRES])
check := hasher.Sum(nil)
if !hmac.Equal(data[SEC_TOKEN_TYPE+SEC_TOKEN_UID+SEC_TOKEN_EXPIRES:], check) {
log.Println("invalid sectoken signature")
return
}
expires = time.Unix(int64(binary.LittleEndian.Uint32(
data[SEC_TOKEN_TYPE+SEC_TOKEN_UID:(SEC_TOKEN_TYPE+SEC_TOKEN_UID+SEC_TOKEN_EXPIRES)])), 0).UTC()
if expires.Before(time.Now()) {
log.Println("expired sectoken")
return
}
return store.UidFromBytes(data[SEC_TOKEN_TYPE:SEC_TOKEN_UID]), expires
}
// Check API key for origin, and revocation. The key must be valid
// origin: browser-provided Origin URL
// dbcheck: if true, validate key & origin by calling the database (should do one on the first connection)
func authClient(appid int32, apikey, origin string) error {
// TODO(gene): validate key with database
// data, err := base64.URLEncoding.DecodeString(apikey)
// var clientid = binary.LittleEndian.Uint16(data[5:7])
// var version = binary.LittleEndian.Uint16(data[7:9])
return nil
}

102
server/crud.go Normal file
View File

@ -0,0 +1,102 @@
/*
{pub} send content intended for topic subscribers
{meta} manipulates topic state, not delivered to topic subscribers directly
CRUD
CRUD is needed for user management (creating/reading/updating own profiles, admins managing profiles of other users)
Users:
C - yes, by {pub} to "!sys.user" (must {sub} to {pub})
R - yes, self - fully by sub to "!me", others - partially by !pres?
U - yes (except some fields) by {pub} to "!sys"?
D - ?
Messages:
C - yes, by {pub} to a topic
R - yes, reading message archive by {meta} to topic
U - no, messages are immutable
D - ?
Topics
C - yes, by {sub} to "!new" (treat it as a shortcut of [{pub} to "!make.topic", then {sub} to the new topic])
create anything by {pub} to "!make.X"?
R - yes, reading the list of topics of interest by {meta browse} to "!pres"
U - yes, changing ACL (sharing), by {meta share} to topic
D - yes, by {meta} or by {unsub} with {"params": {"delete": true}}, garbage collection too?
Topic invites invites (contacts):
C - yes, invite loop
Case 1: user A wants to initiate a p2p conversation or to subscribe to a topic WXY
1. user A initiates invite loop by {sub} to "WXY" or "!new:B"
2. user A gets {ctrl code=1xx} akn from WXY or p2p:A.B that the request is pending approval
3. topic owner B or user B gets {meta} message from !me with a requested access level for WXY or p2p:A.B
4. user B responds by {meta} to "WXY" or "!p2p:A.B" with a UID of A and granted ACL
5. user A gets {ctrl code=XXX} from WXY or !p2p:A.B, and either considered to be subscribed or subscription is rejected
6. multiple invites to the same user/topic are possible
Alternative case 1: user A wants to initiate a p2p conversation or to subscribe to a topic WXY
1. user A sends {sub} to "WXY" or "!new:B"
2. user A gets {ctrl code=4xxx} rejected from WXY or p2p:A.B that access is not allowed
3 User A sends {meta} to topic requesting access
3. topic owner B or user B gets {meta} message from !me with a requested access level for WXY or p2p:A.B
4. user B responds by {meta} to "WXY" or "!p2p:A.B" with a UID of A and granted ACL
5. user A gets {meta} from !me that access is granted/request rejected
6. user A can now {sub} to topic
Case 2: user B wants user A to subscribe to topic WXY
4. user B sends {meta} to "WXY" with UID of A and granted access level
5. user A gets {meta} from "!me" with WXY and granted access level, A can now subscribe to WXY
6. multiple invites to the same user/topic are possible
R - yes, {meta} to !pres or !invite?
U - maybe, change access level: "can message me": Y/N, "can see online status": Y/N
D - yes, user is no longer of interest.
Manage CRUD through dedicated topic(s):
* All CRUD requests go through a single Topic [!meta] or [!sys]
* Individual topics for each type of data, [!sys.users] or [!users]
Pass action/method in Params, payload, if any, in Data
Inbox handling
1. How to deal with topics?
* Treat inbox as a list of "topics of interest", don't automatically subscribe to them.
Like contacts are "users of interest".
Topics
1. If topics have persistent parameters, which must survive server restarts, there should be a way to restore topic
Load existing topic on first subscribe, unload/remove topic after a timeout
2. How to handle messages, accumulated on the topic while the user was away
* Topics must be made persistent: a user can query topic's unread count, manage the access control list
* Need a way to notify user if a topic got pending messages
* Maybe treat contacts and inbox as one and the same? Use !pres as a notification channel
"something happened in the topic of interest".
* create channel for topic management [!meta] or [!sys] or even [!pres]? Maybe use the same topic for contacts?
* User explicitly states what he wants to receive on {sub}:
* unread count (define), ts of the most recent message, ts of the first unread, actual messages (all or some),
number of subscribers
* How to request messages (and topic stats)?
* use a new {meta} client to server message
* pagination in Params like {Since: Time, Before: Time, Limit: Count} or {Between: [Time, Time], Limit: count}
* format of multiple messages, maybe just [msg, msg, ...]?
3. Topics:
* channel to send and receive content;
* label on notifications regarding topic's content changes (when the topic is not currently subscribed)
3.1. Be explicit about subscription to a topic: content + notifications, or notifications only?
* Maybe send all topic notifications on !pres? Even "topic is online"?
4. Double opt-in for contacts (adding someone as contact is the same as subscribing to his/her presence,
must get publisher's concsent), otherwise presence could be leaked
* Invitations to subscribe
* Request for permission to subscribe
* Contact:
a. can write to me: y/n A->B, B->A
b. can read my status: y/n; i want to read his status: y/n; A->B, B->A
* Store p2p links as topics, store the ACL
* that means multiple topic owners
5. All possible topic permissions:
* Can read/{sub}, can write/{pub}, can write only if subscribed ({sub} to {pub}), can manage ACL - invite/ban others,
can delete, can change permissions, can get presence notifications
*/
package main

628
server/datamodel.go Normal file
View File

@ -0,0 +1,628 @@
package main
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : datamode.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Messaging structures
*
* ==Client to server messages
*
* login: authenticate user
* scheme string // optional, defaults to "basic"
* "basic": secret = uname+ ":" + password (not base64 encoded)
* "token": secret = token, obtained earlier
* secret string // optional, authentication string
* expireIn string // optional, string of the form "5m"
* tag string // optional, id of an application instance which created this session
*
* sub: subsribe to a topic (subscribe + attach in one):
* (a) establish a persistent connection between a user and a topic, user wants to receive all messages form a topic
* (b) indicate that the user is ready to receive messages from a topic right now until the user is
* disconnected or unsubscribed
* topic string; // required, name of the topic, [A-Za-z0-9+/=].
* // Special topics:
* "!new": create a new topic and subscribe to it
* "!me": attach, declare your online presence, start receiving targeted publications
* "!pres": attach, topic for presence updates
* mode uint; // access mode
* describe interface{} // optional, topic description, used only when topic = "!new"
*
* unsub: unsubscribe from a topic (detach and unsubscribe in one)
* break the persistent connection between a user and a topic, stop receiving messages
* topic string; // required
*
* pub: publish a message to a topic, {pub} is possible for attached topics only
* topic string; // name of the topic to publish to
* content interface{}; // required, payload, passed unchanged
*
* get: query topic state
* topic string; // name of the topic to query
* action string; // required, type of data to request, one of
"data" - fetch archived messages as {data} packets
"sub" - get subscription info
* "info" - get topic info, requires no payload
* browse *struct {
asc bool // optional - sort results in ascending order by time (desc is the default)
* since *time.Time // optional, return messages newer than this
* before *time.Time // optional, return messages older than this
* limit uint // optional, limit the number of results
* }; // optional, payload for "msg" and "sub" requests, get data between [Since] and [Before],
// limit count to [Limit], defaulting to all data updated since last login on this device
*
* set: request to change topic state
* topic string; // name of the topic to update
* action string; // required, type of data to change, one of
"del" -- delete messages older than specified time
"sub" -- change subscription params
"info" -- update topic params
params *struct {
mode uint; // optional, change current sub.Want mode
public interface{} // optional, public value to update
private interface{} // optional, private value to update
before *time.Time // optional, delete messages older than this
};
*
* ==Server to client messages
*
* ctrl: error or control message
* code int; // HTTP Status code
* text string; // optional text string
* topic string; // optional topic name if the packet is a response in context of a topic
* params map[string]; // optional params
*
* data: content, generic data
* topic string; // name of the originating topic, could be "!usr:<username>"
* origin string: // channel of the person who sent the message, optional
* id int; // optional message id
* content interface{}; // required, payload, passed unchanged
*
* meta: server response to {get} message
* topic string; // name of the topic associated with request
* pres: presence/status change notification
* topic string; // name of the originating topic, could be "!me" for owner-based notifications
* action string; // what happened
* // possible actions:
// on, off - user went online/offline
// sub, unsub -- user subscriped or unsubscribed
// in, out -- user joined/left topic
// upd -- user or topic has upadated description
* who string; // required, user or topic which changed the state
*
*****************************************************************************/
import (
"net/http"
"reflect"
"strings"
"time"
)
type JsonDuration time.Duration
func (jd *JsonDuration) UnmarshalJSON(data []byte) (err error) {
d, err := time.ParseDuration(strings.Trim(string(data), "\""))
*jd = JsonDuration(d)
return err
}
type MsgBrowseOpts struct {
Ascnd bool `json:"ascnd,omitempty"` // true - sort in scending order by time, otherwise descending (default)
Since *time.Time `json:"since,omitempty"` // Load/count objects newer than this
Before *time.Time `json:"before,omitempty"` // Load/count objects older than this
Limit uint `json:"limit,omitempty"` // Limit the number of objects loaded or counted
}
// Client to Server (C2S) messages
// User creation message {acc}
type MsgClientAcc struct {
Id string `json:"id,omitempty"` // Message Id
User string `json:"user"` // "new" to create a new user or UserId to update a user; default: current user
Auth []MsgAuthScheme `json:"auth"`
// User initialization data when creating a new user, otherwise ignored
Init *MsgSetInfo `json:"init,omitempty"`
}
type MsgAuthScheme struct {
// Scheme name
Scheme string `json:"scheme"`
Secret string `json:"secret"`
}
// Login {login} message
type MsgClientLogin struct {
Id string `json:"id,omitempty"` // Message Id
Scheme string `jdon:"scheme,omitempty"` // Authentication scheme
Secret string `json:"secret"` // Shared secret
ExpireIn JsonDuration `json:"expireIn,omitempty"` // Login expiration time
Tag string `json:"tag,omitempty"` // Device Id
}
// Subscription request {sub} message
type MsgClientSub struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
// Topic initialization data, !new topic & new subscriptions only, mirrors {set info}
Init *MsgSetInfo `json:"init,omitempty"`
// Subscription parameters, mirrors {set sub}; sub.User must not be provided
Sub *MsgSetSub `json:"sub,omitempty"`
// mirrors get.what: "data", "sub", "info", default: get nothing
// space separated list; unknown strings are ignored
Get string `json:"get,omitempty"`
// parameter of the request data from topic, mirrors get.browse
Browse *MsgBrowseOpts `json:"browse,omitempty"`
}
const (
constMsgMetaInfo = 1 << iota
constMsgMetaSub
constMsgMetaData
constMsgMetaDelTopic
constMsgMetaDelMsg
)
func parseMsgClientMeta(params string) int {
var bits int
parts := strings.SplitN(params, " ", 8)
for _, p := range parts {
switch p {
case "info":
bits |= constMsgMetaInfo
case "sub":
bits |= constMsgMetaSub
case "data":
bits |= constMsgMetaData
default:
// ignore
}
}
return bits
}
// MsgSetInfo: C2S in set.what == "info" and sub.init message
type MsgSetInfo struct {
DefaultAcs *MsgDefaultAcsMode `json:"defacs,omitempty"` // Access mode
Public interface{} `json:"public,omitempty"`
Private interface{} `json:"private,omitempty"` // Per-subscription private data
}
// MsgSetSub: payload in set.sub request to update current subscription or invite another user, {sub.what} == "sub"
type MsgSetSub struct {
// User affected by this request. Default (empty): current user
User string `json:"user,omitempty"`
// Access mode change, either Given or Want depending on context
Mode string `json:"mode,omitempty"`
// Free-form payload to pass to the invited user or to topic manager
Info interface{} `json:"info,omitempty"`
}
// Topic default access mode
type MsgDefaultAcsMode struct {
Auth string `json:"auth,omitempty"`
Anon string `json:"anon,omitempty"`
}
// Unsubscribe {leave} request message
type MsgClientLeave struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
Unsub bool `json:unsub,omitempty`
}
// MsgClientPub is client's request to publish data to topic subscribers {pub}
type MsgClientPub struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
Content interface{} `json:"content"`
}
//func (msg *MsgClientPub) GetBoolParam(name string) bool {
// return modelGetBoolParam(msg.Params, name)
//}
// Query topic state {get}
type MsgClientGet struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
What string `json:"what"` // data, sub, info, space separated list; unknown strings are ignored
Browse *MsgBrowseOpts `json:"browse,omitempty"`
}
// Update topic state {set}
type MsgClientSet struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
What string `json:"what"` // sub, info, space separated list; unknown strings are ignored
Info *MsgSetInfo `json:"info,omitempty"` // Payload for What == "info"
Sub *MsgSetSub `json:"sub,omitempty"` // Payload for What == "sub"
}
// MsgClientDel delete messages or topic
type MsgClientDel struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
// what to delete, either "msg" to delete messages (default) or "topic" to delete the topic
What string `json:"what"`
// Delete messages older than this time stamp (inclusive)
Before time.Time `json:"before"`
// Request to hard-delete messages for all users, if such option is available.
Hard bool `json:"hard,omitempty"`
}
type ClientComMessage struct {
Acc *MsgClientAcc `json:"acc"`
Login *MsgClientLogin `json:"login"`
Sub *MsgClientSub `json:"sub"`
Leave *MsgClientLeave `json:"leave"`
Pub *MsgClientPub `json:"pub"`
Get *MsgClientGet `json:"get"`
Set *MsgClientSet `json:"set"`
Del *MsgClientDel `json:"del"`
// from: userid as string
from string
timestamp time.Time
}
// *********************************************************
// Server to client messages
type MsgLastSeenInfo struct {
When time.Time `json:"when"` // when the user was last seen
Tag string `json:"tag,omitempty"` // tag of the device used to access the topic
}
// Topic info, S2C in Meta message
type MsgTopicInfo struct {
CreatedAt *time.Time `json:"created,omitempty"`
UpdatedAt *time.Time `json:"updated,omitempty"`
Name string `json:"name,omitempty"`
DefaultAcs *MsgDefaultAcsMode `json:"defacs,omitempty"`
Acs *MsgAccessMode `json:"acs,omitempty"` // Actual access mode
LastMessage *time.Time `json:"lastMsg,omitempty"` // time of the last {data} message in the topic
LastSeen *MsgLastSeenInfo `json:"seen,omitempty"` // user's last access to topic
LastSeenTag *time.Time `json:"seenTag,omitempty"` // user's last access to topic with the given tag (device)
Public interface{} `json:"public,omitempty"`
Private interface{} `json:"private,omitempty"` // Per-subscription private data
}
type MsgAccessMode struct {
Want string `json:"want,omitempty"`
Given string `json:"given,omitempty"`
}
// MsgTopicSub: topic subscription details, sent in Meta message
type MsgTopicSub struct {
Topic string `json:"topic,omitempty"`
// p2p topics only - id of the other user
With string `json:"with,omitempty"`
User string `json:"user,omitempty"`
UpdatedAt time.Time `json:"updated"`
// 'me' topic only
LastMsg *time.Time `json:"lastMsg,omitempty"` // last message in a topic, "me' subs only
LastSeen *MsgLastSeenInfo `json:"seen,omitempty"` // user's last access to topic, 'me' subs only
LastSeenTag *time.Time `json:"seenTag,omitempty"` // user's last access to topic with the given tag (device)
// cumulative access mode (mode.Want & mode.Given)
AcsMode string `json:"mode"`
Public interface{} `json:"public,omitempty"`
Private interface{} `json:"private,omitempty"`
}
type MsgServerCtrl struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic,omitempty"`
Params interface{} `json:"params,omitempty"`
Code int `json:"code"`
Text string `json:"text,omitempty"`
Timestamp time.Time `json:"ts"`
}
// Invitation to a topic, sent as MsgServerData.Content
type MsgInvitation struct {
// Topic that user wants to subscribe to or is invited to
Topic string `json:"topic"`
// User being subscribed
User string `json:"user"`
// Type of this invite - InvJoin, InvAppr
Action string `json:"act"`
// Current state of the access mode
Acs MsgAccessMode `json:"acs,omitempty"`
// Free-form payload
Info interface{} `json:"info,omitempty"`
}
type MsgServerData struct {
Topic string `json:"topic"`
From string `json:"from,omitempty"` // could be empty if sent by system
Timestamp time.Time `json:"ts"`
Content interface{} `json:"content"`
}
type MsgServerPres struct {
Topic string `json:"topic"`
User string `json:"user,omitempty"`
What string `json:"what"`
}
type MsgServerMeta struct {
Id string `json:"id,omitempty"`
Topic string `json:"topic"`
Timestamp *time.Time `json:"ts,omitempty"`
Info *MsgTopicInfo `json:"info,omitempty"` // Topic description
Sub []MsgTopicSub `json:"sub,omitempty"` // Subscriptions as an array of objects
}
type ServerComMessage struct {
Ctrl *MsgServerCtrl `json:"ctrl,omitempty"`
Data *MsgServerData `json:"data,omitempty"`
Meta *MsgServerMeta `json:"meta,omitempty"`
Pres *MsgServerPres `json:"pres,omitempty"`
// to: topic
rcptto string
// appid, also for routing
appid uint32
// originating session, copy of Session.send
akn chan<- []byte
// origin-specific id to use in {ctrl} aknowledgements
id string
// timestamp for consistency of timestamps in {ctrl} messages
timestamp time.Time
}
// Combined message
type ComMessage struct {
*ClientComMessage
*ServerComMessage
}
func modelGetBoolParam(params map[string]interface{}, name string) bool {
var val bool
if params != nil {
if param, ok := params[name]; ok {
switch param.(type) {
case bool:
val = param.(bool)
case float64:
val = (param.(float64) != 0.0)
}
}
}
return val
}
func modelGetInt64Param(params map[string]interface{}, name string) int64 {
var val int64
if params != nil {
if param, ok := params[name]; ok {
switch param.(type) {
case int8, int16, int32, int64, int:
val = reflect.ValueOf(param).Int()
case float32, float64:
val = int64(reflect.ValueOf(param).Float())
}
}
}
return val
}
// Generators of error messages
func NoErr(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusOK, // 200
Text: "ok",
Topic: topic,
Timestamp: ts}}
return msg
}
func NoErrCreated(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusCreated, // 201
Text: "created",
Topic: topic,
Timestamp: ts}}
return msg
}
func NoErrAccepted(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusAccepted, // 202
Text: "message accepted for delivery",
Topic: topic,
Timestamp: ts}}
return msg
}
// 3xx
func InfoAlreadySubscribed(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusNotModified, // 304
Text: "already subscribed",
Topic: topic,
Timestamp: ts}}
return msg
}
func InfoNotSubscribed(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusNotModified, // 304
Text: "not subscribed",
Topic: topic,
Timestamp: ts}}
return msg
}
// 4xx Errors
func ErrMalformed(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusBadRequest, // 400
Text: "malformed message",
Topic: topic,
Timestamp: ts}}
return msg
}
/*
func ErrUnrecognized(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusBadRequest, // 400
Text: "unrecognized input",
Topic: topic,
Timestamp: ts}}
return msg
}
*/
func ErrAuthRequired(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusUnauthorized, // 401
Text: "authentication required",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrAuthFailed(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusUnauthorized, // 401
Text: "authentication failed",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrAuthUnknownScheme(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusUnauthorized, // 401
Text: "unknown or missing authentication scheme",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrPermissionDenied(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusForbidden, // 403
Text: "access denied",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrTopicNotFound(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusNotFound,
Text: "topic not found", // 404
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrUserNotFound(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusNotFound, // 404
Text: "user not found or offline",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrAlreadyAuthenticated(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusConflict, // 409
Text: "already authenticated",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrDuplicateCredential(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusConflict, // 409
Text: "duplicate credential",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrGone(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusGone, // 410
Text: "gone",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrUnknown(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusInternalServerError, // 500
Text: "internal error",
Topic: topic,
Timestamp: ts}}
return msg
}
func ErrNotImplemented(id, topic string, ts time.Time) *ServerComMessage {
msg := &ServerComMessage{Ctrl: &MsgServerCtrl{
Id: id,
Code: http.StatusNotImplemented, // 501
Text: "not implemented",
Topic: topic,
Timestamp: ts}}
return msg
}

View File

@ -0,0 +1,755 @@
package rethinkdb
import (
"encoding/base64"
"errors"
rdb "github.com/dancannon/gorethink"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
"log"
"net/url"
"strconv"
"strings"
"time"
)
type RethinkDbAdapter struct {
conn *rdb.Session
dbName string
}
const (
defaultPort = 28015
defaultHost = "localhost"
defaultDatabase = "tinode"
)
var uGen uidGenerator
// Open eturns an initialized rethinkdb session
func (a *RethinkDbAdapter) Open(dsn string) error {
if a.conn != nil {
return errors.New("adapter rethinkdb is already connected")
}
//dsn: "rethinkdb://localhost:28015/database_name?authKey=&discover=false&maxIdle=&maxOpen=&timeout=&workerId=1&uidkey=base64_encoded_string")
parts, err := url.Parse(dsn)
if err != nil {
return err
}
//Parse connection options passed as query parameters
params := parts.Query()
// Initialise snowflake
var workerId int
if v, ok := params["workerId"]; ok && len(v) > 0 {
// We can safely ignore the error here because it returns the default 0
workerId, _ = strconv.Atoi(v[0])
}
var uidkey []byte
if v, ok := params["uidkey"]; ok {
dl := base64.URLEncoding.DecodedLen(len(v[0]))
if base64.URLEncoding.EncodedLen(dl) != len(v[0]) {
return errors.New("rethinkdb adapter: unable to base64-decode uidkey")
}
uidkey, err = base64.URLEncoding.DecodeString(v[0])
if err != nil {
return err
}
}
err = uGen.Init(uint(workerId), uidkey)
if err != nil {
return err
}
// Initialize database connection
if parts.Host == "" {
parts.Host = defaultHost + ":" + strconv.Itoa(defaultPort)
}
if parts.Path == "" {
a.dbName = defaultDatabase
} else {
a.dbName = parts.Path[1:] // path is returned as "/path", strip leading '/'
}
opts := rdb.ConnectOpts{
Address: parts.Host,
Database: a.dbName,
}
// We can safely ignore the conversion errors here because Atoi returns the default 0 on error
if maxIdle, ok := params["maxIdle"]; ok && len(maxIdle) > 0 {
opts.MaxIdle, _ = strconv.Atoi(maxIdle[0])
}
if maxOpen, ok := params["maxOpen"]; ok && len(maxOpen) > 0 {
opts.MaxOpen, _ = strconv.Atoi(maxOpen[0])
}
if timeout, ok := params["timeout"]; ok && len(timeout) > 0 {
to, _ := strconv.Atoi(timeout[0])
opts.Timeout = time.Duration(to) * time.Second
}
a.conn, err = rdb.Connect(opts)
return err
}
// Close closes the underlying database connection
func (a *RethinkDbAdapter) Close() error {
var err error
if a.conn != nil {
err = a.conn.Close(rdb.CloseOpts{NoReplyWait: false})
a.conn = nil
}
return err
}
// IsOpen returns true if connection to database has been established. It does not check if
// connection is actually live.
func (a *RethinkDbAdapter) IsOpen() bool {
return a.conn != nil
}
// ResetDb re-initializes the storage. All data is lost.
func (a *RethinkDbAdapter) ResetDb() error {
// Drop database if exists, ignore error if it does not.
rdb.DBDrop("tinode").RunWrite(a.conn)
if _, err := rdb.DBCreate("tinode").RunWrite(a.conn); err != nil {
return err
}
// Users
if _, err := rdb.DB("tinode").TableCreate("users", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
return err
}
if _, err := rdb.DB("tinode").Table("users").IndexCreate("Username").RunWrite(a.conn); err != nil {
return err
}
// Subscription to a topic. The primary key is a Topic:User string
if _, err := rdb.DB("tinode").TableCreate("subscriptions", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
return err
}
if _, err := rdb.DB("tinode").Table("subscriptions").IndexCreateFunc("User_UpdatedAt",
func(row rdb.Term) interface{} {
return []interface{}{row.Field("User"), row.Field("UpdatedAt")}
}).RunWrite(a.conn); err != nil {
return err
}
if _, err := rdb.DB("tinode").Table("subscriptions").IndexCreateFunc("Topic_UpdatedAt",
func(row rdb.Term) interface{} {
return []interface{}{row.Field("Topic"), row.Field("UpdatedAt")}
}).RunWrite(a.conn); err != nil {
return err
}
// Topic stored in database
if _, err := rdb.DB("tinode").TableCreate("topics", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
return err
}
if _, err := rdb.DB("tinode").Table("topics").IndexCreate("Name").RunWrite(a.conn); err != nil {
return err
}
// Stored message
if _, err := rdb.DB("tinode").TableCreate("messages", rdb.TableCreateOpts{PrimaryKey: "Id"}).RunWrite(a.conn); err != nil {
return err
}
if _, err := rdb.DB("tinode").Table("messages").IndexCreateFunc("Topic_CreatedAt",
func(row rdb.Term) interface{} {
return []interface{}{row.Field("Topic"), row.Field("CreatedAt")}
}).RunWrite(a.conn); err != nil {
return err
}
// Index for unique fields
if _, err := rdb.DB("tinode").TableCreate("_uniques").RunWrite(a.conn); err != nil {
return err
}
return nil
}
// UserCreate creates a new user. Returns error and bool - true if error is due to duplicate user name
func (a *RethinkDbAdapter) UserCreate(appId uint32, user *t.User) (error, bool) {
// FIXME(gene): Rethink has no support for transactions. Prevent other routines from using
// the username currently being processed. For instance, keep it in a shared map for the duration of transaction
// Check username for uniqueness (no built-in support for secondary unique indexes in RethinkDB)
value := "users!username!" + user.Username // unique value=primary key
_, err := rdb.DB(a.dbName).Table("_uniques").Insert(map[string]string{"id": value}).RunWrite(a.conn)
if err != nil {
if strings.Contains(err.Error(), "Duplicate primary key") {
return errors.New("duplicate credential"), true
}
return err, false
}
// Validated unique username, inserting user now
user.SetUid(uGen.Get())
_, err = rdb.DB(a.dbName).Table("users").Insert(&user).RunWrite(a.conn)
if err != nil {
// Delete inserted _uniques entries in case of errors
rdb.DB(a.dbName).Table("_uniques").Get(value).Delete(rdb.DeleteOpts{Durability: "soft"}).RunWrite(a.conn)
return err, false
}
return nil, false
}
// Users
func (a *RethinkDbAdapter) GetPasswordHash(appid uint32, uname string) (t.Uid, []byte, error) {
var err error
rows, err := rdb.DB(a.dbName).Table("users").GetAllByIndex("Username", uname).
Pluck("Id", "Passhash").Run(a.conn)
if err != nil {
return t.ZeroUid, nil, err
}
var user t.User
if rows.Next(&user) {
//log.Println("loggin in user Id=", user.Uid(), user.Id)
if user.Uid().IsZero() {
return t.ZeroUid, nil, errors.New("internal: invalid Uid")
}
} else {
// User not found
return t.ZeroUid, nil, nil
}
return user.Uid(), user.Passhash, nil
}
// UserGet fetches a single user by user id. If user is not found it returns (nil, nil)
func (a *RethinkDbAdapter) UserGet(appid uint32, uid t.Uid) (*t.User, error) {
if row, err := rdb.DB(a.dbName).Table("users").Get(uid.String()).Run(a.conn); err == nil && !row.IsNil() {
var user t.User
if err = row.One(&user); err == nil {
user.Passhash = nil
return &user, nil
}
return nil, err
} else {
// If user does not exist, it returns nil, nil
return nil, err
}
}
func (a *RethinkDbAdapter) UserGetAll(appId uint32, ids []t.Uid) ([]t.User, error) {
uids := []interface{}{}
for _, id := range ids {
uids = append(uids, id.String())
}
users := []t.User{}
if rows, err := rdb.DB(a.dbName).Table("users").GetAll(uids...).Run(a.conn); err != nil {
return nil, err
} else {
var user t.User
for rows.Next(&user) {
user.Passhash = nil
users = append(users, user)
}
if err = rows.Err(); err != nil {
return nil, err
}
}
return users, nil
}
func (a *RethinkDbAdapter) UserFind(appId uint32, params map[string]interface{}) ([]t.User, error) {
return nil, errors.New("UserFind: not implemented")
}
/*
func (a *RethinkDbAdapter) GetLastSeenAndStatus(appid uint32, uid t.Uid) (time.Time, interface{}, error) {
row, err := rdb.DB(a.dbName).Table("users").Get(uid.String()).Pluck("lastSeen", "status").Run(a.conn)
var timeDefault = time.Unix(1414213562, 0).UTC() // sqrt(2) == 2014-10-25T05:06:02Z
if err == nil && !row.IsNil() {
var data struct {
LastSeen time.Time `gorethink:"lastSeen"`
Status interface{} `gorethink:"status"`
}
if err = row.One(&data); err == nil {
if data.LastSeen.IsZero() {
data.LastSeen = timeDefault
}
return data.LastSeen, data.Status, nil
}
}
return timeDefault, nil, err
}
*/
func (a *RethinkDbAdapter) UserDelete(appId uint32, id t.Uid, soft bool) error {
return errors.New("UserDelete: not implemented")
}
func (a *RethinkDbAdapter) UserUpdateStatus(appid uint32, uid t.Uid, status interface{}) error {
update := map[string]interface{}{"Status": status}
_, err := rdb.DB(a.dbName).Table("users").Get(uid.String()).
Update(update, rdb.UpdateOpts{Durability: "soft"}).RunWrite(a.conn)
return err
}
func (a *RethinkDbAdapter) ChangePassword(appid uint32, id t.Uid, password string) error {
return nil
}
func (a *RethinkDbAdapter) UserUpdate(appid uint32, uid t.Uid, update map[string]interface{}) error {
_, err := rdb.DB(a.dbName).Table("users").Get(uid.String()).Update(update).RunWrite(a.conn)
return err
}
// *****************************
// TopicCreate creates a topic from template
func (a *RethinkDbAdapter) TopicCreate(appId uint32, topic *t.Topic) error {
// Validated unique username, inserting user now
topic.SetUid(uGen.Get())
_, err := rdb.DB(a.dbName).Table("topics").Insert(&topic).RunWrite(a.conn)
return err
}
// TopicCreateP2P given two users creates a p2p topic
func (a *RethinkDbAdapter) TopicCreateP2P(appId uint32, initiator, invited *t.Subscription) error {
initiator.Id = initiator.Topic + ":" + initiator.User
// Don't care if the initiator changes own subscription
_, err := rdb.DB(a.dbName).Table("subscriptions").Insert(initiator, rdb.InsertOpts{Conflict: "replace"}).
RunWrite(a.conn)
if err != nil {
return err
}
// Ensure this is a new subscription. If one already exist, don't overwrite it
invited.Id = invited.Topic + ":" + invited.User
_, err = rdb.DB(a.dbName).Table("subscriptions").Insert(invited, rdb.InsertOpts{Conflict: "error"}).
RunWrite(a.conn)
if err != nil {
// Is this a duplicate subscription? If so, ifnore it. Otherwise it's a genuine DB error
if !strings.Contains(err.Error(), "Duplicate primary key") {
return err
}
}
topic := &t.Topic{
Name: initiator.Topic,
Access: t.DefaultAccess{Auth: t.ModeBanned, Anon: t.ModeBanned}}
topic.ObjHeader.MergeTimes(&initiator.ObjHeader)
return a.TopicCreate(appId, topic)
}
func (a *RethinkDbAdapter) TopicGet(appid uint32, topic string) (*t.Topic, error) {
// Fetch topic by name
rows, err := rdb.DB(a.dbName).Table("topics").GetAllByIndex("Name", topic).Run(a.conn)
if err != nil {
return nil, err
}
if rows.IsNil() {
return nil, nil
}
var tt = new(t.Topic)
if err = rows.One(tt); err != nil {
return nil, err
}
return tt, rows.Err()
}
// TopicsForUser loads user's topics and contacts
func (a *RethinkDbAdapter) TopicsForUser(appid uint32, uid t.Uid, opts *t.BrowseOpt) ([]t.Subscription, error) {
// Fetch user's subscriptions
// Subscription have Topic.UpdatedAt denormalized into Subscription.UpdatedAt
q := rdb.DB(a.dbName).Table("subscriptions")
q = addLimitAndFilter(q, uid.String(), "User_UpdatedAt", opts)
//log.Printf("RethinkDbAdapter.TopicsForUser q: %+v", q)
rows, err := q.Run(a.conn)
if err != nil {
return nil, err
}
// Fetch subscriptions. Two queries are needed: to users table (me & p2p) and topics table (p2p and grp).
// Prepare a list of Separate subscriptions to users vs topics
var sub t.Subscription
join := make(map[string]t.Subscription) // Keeping these to make a join with table for .private and .access
topq := make([]interface{}, 0, 16)
usrq := make([]interface{}, 0, 16)
for rows.Next(&sub) {
// 'me' subscription, skip
if strings.HasPrefix(sub.Topic, "usr") {
continue
// p2p subscription, find the other user to get user.Public
} else if strings.HasPrefix(sub.Topic, "p2p") {
uid1, uid2, _ := t.ParseP2P(sub.Topic)
if uid1 == uid {
usrq = append(usrq, uid2.String())
} else {
usrq = append(usrq, uid1.String())
}
topq = append(topq, sub.Topic)
// grp subscription
} else {
topq = append(topq, sub.Topic)
}
join[sub.Topic] = sub
}
//log.Printf("RethinkDbAdapter.TopicsForUser topq, usrq: %+v, %+v", topq, usrq)
var subs []t.Subscription
if len(topq) > 0 || len(usrq) > 0 {
subs = make([]t.Subscription, 0, len(join))
}
if len(topq) > 0 {
// Fetch grp & p2p topics
rows, err = rdb.DB(a.dbName).Table("topics").GetAllByIndex("Name", topq...).Run(a.conn)
if err != nil {
return nil, err
}
var top t.Topic
for rows.Next(&top) {
sub = join[top.Name]
sub.ObjHeader.MergeTimes(&top.ObjHeader)
sub.LastMessageAt = top.LastMessageAt
if strings.HasPrefix(sub.Topic, "grp") {
// all done with a grp topic
sub.SetPublic(top.Public)
subs = append(subs, sub)
} else {
// put back the updated value of a p2p subsription, will process further below
join[top.Name] = sub
}
}
log.Printf("RethinkDbAdapter.TopicsForUser 1: %#+v", subs)
}
// Fetch p2p users and join to p2p tables
if len(usrq) > 0 {
rows, err = rdb.DB(a.dbName).Table("users").GetAll(usrq...).Run(a.conn)
if err != nil {
return nil, err
}
var usr t.User
for rows.Next(&usr) {
uid2 := t.ParseUid(usr.Id)
topic := uid.P2PName(uid2)
if sub, ok := join[topic]; ok {
sub.ObjHeader.MergeTimes(&usr.ObjHeader)
sub.SetWith(uid2.UserId())
sub.SetPublic(usr.Public)
subs = append(subs, sub)
}
}
log.Printf("RethinkDbAdapter.TopicsForUser 2: %#+v", subs)
}
return subs, nil
}
// UsersForTopic loads users subscribed to the given topic
func (a *RethinkDbAdapter) UsersForTopic(appid uint32, topic string, opts *t.BrowseOpt) ([]t.Subscription, error) {
// Fetch topic subscribers
// Fetch all subscribed users. The number of users is not large
q := rdb.DB(a.dbName).Table("subscriptions")
q = addLimitAndFilter(q, topic, "Topic_UpdatedAt", nil)
//log.Printf("RethinkDbAdapter.UsersForTopic q: %+v", q)
rows, err := q.Run(a.conn)
if err != nil {
return nil, err
}
// Fetch subscriptions
var sub t.Subscription
var subs []t.Subscription
join := make(map[string]t.Subscription)
usrq := make([]interface{}, 0, 16)
for rows.Next(&sub) {
join[sub.User] = sub
usrq = append(usrq, sub.User)
}
//log.Printf("RethinkDbAdapter.UsersForTopic usrq: %+v, usrq)
if len(usrq) > 0 {
subs = make([]t.Subscription, 0, len(usrq))
// Fetch users by a list of subscriptions
rows, err = rdb.DB(a.dbName).Table("users").GetAll(usrq...).Run(a.conn)
if err != nil {
return nil, err
}
var usr t.User
for rows.Next(&usr) {
if sub, ok := join[usr.Id]; ok {
sub.ObjHeader.MergeTimes(&usr.ObjHeader)
sub.SetPublic(usr.Public)
subs = append(subs, sub)
}
}
//log.Printf("RethinkDbAdapter.UsersForTopic users: %+v", subs)
}
return subs, nil
}
func (a *RethinkDbAdapter) TopicShare(appid uint32, shares []t.Subscription) (int, error) {
// Assign Ids
for i := 0; i < len(shares); i++ {
shares[i].Id = shares[i].Topic + ":" + shares[i].User
}
resp, err := rdb.DB(a.dbName).Table("subscriptions").Insert(shares).RunWrite(a.conn)
if err != nil {
return resp.Inserted, err
}
return resp.Inserted, nil
}
func (a *RethinkDbAdapter) TopicDelete(appId uint32, userDbId, topic string) error {
return errors.New("TopicDelete: not implemented")
}
func (a *RethinkDbAdapter) TopicUpdateLastMsgTime(appid uint32, topic string, ts time.Time) error {
update := struct {
LastMessageAt *time.Time
}{&ts}
// Invite - 'me' topic
var err error
if strings.HasPrefix(topic, "usr") {
_, err = rdb.DB("tinode").Table("subscriptions").
Get(topic+":"+t.ParseUserId(topic).String()).
Update(update, rdb.UpdateOpts{Durability: "soft"}).RunWrite(a.conn)
// All other messages
} else {
_, err = rdb.DB("tinode").Table("topics").GetAllByIndex("Name", topic).
Update(update, rdb.UpdateOpts{Durability: "soft"}).RunWrite(a.conn)
}
return err
}
// UpdateLastSeen records the time when a session with a given device ID detached from a topic
func (a *RethinkDbAdapter) UpdateLastSeen(appid uint32, topic string, user t.Uid, tag string, when time.Time) error {
update := struct {
LastSeen map[string]time.Time
}{map[string]time.Time{tag: when}}
_, err := rdb.DB("tinode").Table("subscriptions").Get(topic+":"+user.String()).
Update(update, rdb.UpdateOpts{Durability: "soft"}).RunWrite(a.conn)
return err
}
func (a *RethinkDbAdapter) TopicUpdate(appid uint32, topic string, update map[string]interface{}) error {
_, err := rdb.DB("tinode").Table("topics").GetAllByIndex("Name", topic).Update(update).RunWrite(a.conn)
return err
}
// Get a subscription of a user to a topic
func (a *RethinkDbAdapter) SubscriptionGet(appid uint32, topic string, user t.Uid) (*t.Subscription, error) {
rows, err := rdb.DB(a.dbName).Table("subscriptions").Get(topic + ":" + user.String()).Run(a.conn)
if err != nil {
return nil, err
}
var sub t.Subscription
err = rows.One(&sub)
return &sub, rows.Err()
}
// Update time when the user was last attached to the topic
func (a *RethinkDbAdapter) SubsLastSeen(appid uint32, topic string, user t.Uid, lastSeen map[string]time.Time) error {
_, err := rdb.DB(a.dbName).Table("subscriptions").Get(topic+":"+user.String()).
Update(map[string]interface{}{"LastSeen": lastSeen}, rdb.UpdateOpts{Durability: "soft"}).RunWrite(a.conn)
return err
}
// SubsForUser loads a list of user's subscriptions to topics
func (a *RethinkDbAdapter) SubsForUser(appid uint32, forUser t.Uid, opts *t.BrowseOpt) ([]t.Subscription, error) {
if forUser.IsZero() {
return nil, errors.New("RethinkDb adapter: invalid user ID in TopicGetAll")
}
q := rdb.DB(a.dbName).Table("subscriptions")
q = addLimitAndFilter(q, forUser.String(), "User_UpdatedAt", opts)
rows, err := q.Run(a.conn)
if err != nil {
return nil, err
}
var subs []t.Subscription
var ss t.Subscription
for rows.Next(&ss) {
subs = append(subs, ss)
}
return subs, rows.Err()
}
// SubsForTopic fetches all subsciptions for a topic.
func (a *RethinkDbAdapter) SubsForTopic(appId uint32, topic string, opts *t.BrowseOpt) ([]t.Subscription, error) {
//log.Println("Loading subscriptions for topic ", topic)
// must load User.Public for p2p topics
var p2p []t.User
if strings.HasPrefix(topic, "p2p") {
uid1, uid2, _ := t.ParseP2P(topic)
if p2p, err := a.UserGetAll(appId, []t.Uid{uid1, uid2}); err != nil {
return nil, err
} else if len(p2p) != 2 {
return nil, errors.New("failed to load two p2p users")
}
}
q := rdb.DB(a.dbName).Table("subscriptions")
q = addLimitAndFilter(q, topic, "Topic_UpdatedAt", opts)
//log.Println("Loading subscription q=", q)
rows, err := q.Run(a.conn)
if err != nil {
return nil, err
}
var subs []t.Subscription
var ss t.Subscription
for rows.Next(&ss) {
if p2p != nil {
if p2p[0].Id == ss.User {
ss.SetPublic(p2p[1].Public)
ss.SetWith(p2p[1].Id)
} else {
ss.SetPublic(p2p[0].Public)
ss.SetWith(p2p[0].Id)
}
}
subs = append(subs, ss)
log.Printf("SubsForTopic: loaded sub %#+v", ss)
}
return subs, rows.Err()
}
// Update a single subscription.
func (a *RethinkDbAdapter) SubsUpdate(appid uint32, topic string, user t.Uid, update map[string]interface{}) error {
_, err := rdb.DB(a.dbName).Table("subscriptions").Get(topic + ":" + user.String()).Update(update).RunWrite(a.conn)
return err
}
// Messages
func (a *RethinkDbAdapter) MessageSave(appId uint32, msg *t.Message) error {
msg.SetUid(uGen.Get())
_, err := rdb.DB(a.dbName).Table("messages").Insert(msg).RunWrite(a.conn)
return err
}
func (a *RethinkDbAdapter) MessageGetAll(appId uint32, topic string, opts *t.BrowseOpt) ([]t.Message, error) {
//log.Println("Loading messages for topic ", topic)
q := rdb.DB(a.dbName).Table("messages")
q = addLimitAndFilter(q, topic, "Topic_CreatedAt", opts)
rows, err := q.Run(a.conn)
if err != nil {
return nil, err
}
var msgs []t.Message
var mm t.Message
for rows.Next(&mm) {
msgs = append(msgs, mm)
}
return msgs, rows.Err()
}
func (a *RethinkDbAdapter) MessageDelete(appId uint32, id t.Uid) error {
return errors.New("MessageDelete: not implemented")
}
func addLimitAndFilter(q rdb.Term, value string, index string, opts *t.BrowseOpt) rdb.Term {
var limit uint = 1024 // TODO(gene): pass into adapter as a config param
var lower, upper interface{}
var order rdb.Term
if opts != nil {
if !opts.Since.IsZero() {
lower = opts.Since
} else {
lower = rdb.MinVal
}
if !opts.Before.IsZero() {
upper = opts.Before
} else {
upper = rdb.MaxVal
}
if value != "" {
lower = []interface{}{value, lower}
upper = []interface{}{value, upper}
}
if opts.Limit > 0 && opts.Limit < limit {
limit = opts.Limit
}
if opts.AscOrder {
order = rdb.Asc(index)
} else {
order = rdb.Desc(index)
}
} else {
lower = []interface{}{value, rdb.MinVal}
upper = []interface{}{value, rdb.MaxVal}
order = rdb.Desc(index)
}
return q.Between(lower, upper, rdb.BetweenOpts{Index: index}).
OrderBy(rdb.OrderByOpts{Index: order}).Limit(limit)
}
/*
func remapP2PTopic(topic string, user t.Uid) (string, error) {
if strings.HasPrefix(topic, "p2p") {
uid1, uid2, err := t.ParseP2P(topic)
if err != nil {
return "", err
}
if user == uid1 {
topic = uid2.UserId()
} else {
topic = uid1.UserId()
}
}
return topic, nil
}
*/
func init() {
store.Register(&RethinkDbAdapter{})
}

View File

@ -0,0 +1,69 @@
package rethinkdb
import (
"encoding/base64"
"encoding/binary"
t "github.com/tinode/chat/server/store/types"
sf "github.com/tinode/snowflake"
"golang.org/x/crypto/xtea"
)
// RethinkDB generates UUIDs as primary keys. Using snowflake-generated uint64 instead.
type uidGenerator struct {
seq *sf.SnowFlake
cipher *xtea.Cipher
}
// Init initialises the Uid generator
func (uid *uidGenerator) Init(workerId uint, key []byte) error {
var err error
if uid.seq == nil {
uid.seq, err = sf.NewSnowFlake(uint32(workerId))
}
if uid.cipher == nil {
uid.cipher, err = xtea.NewCipher(key)
}
return err
}
// Get generates a unique weakly encryped id it so ids are random-looking.
func (uid *uidGenerator) Get() t.Uid {
buf, err := getIdBuffer(uid)
if err != nil {
return t.ZeroUid
}
return t.Uid(binary.LittleEndian.Uint64(buf))
}
// 8 bytes of data are always encoded as 11 bytes of base64 + 1 byte of padding.
const (
BASE64_PADDED = 12
BASE64_UNPADDED = 11
)
// GetStr generates a unique id then returns it as base64-encrypted string.
func (uid *uidGenerator) GetStr() string {
buf, err := getIdBuffer(uid)
if err != nil {
return ""
}
return base64.URLEncoding.EncodeToString(buf)[:BASE64_UNPADDED]
}
// getIdBuffer returns a byte array holding the Uid bytes
func getIdBuffer(uid *uidGenerator) ([]byte, error) {
var id uint64
var err error
if id, err = uid.seq.Next(); err != nil {
return nil, err
}
var src = make([]byte, 8)
var dst = make([]byte, 8)
binary.LittleEndian.PutUint64(src, id)
uid.cipher.Encrypt(dst, src)
return dst, nil
}

650
server/hub.go Normal file
View File

@ -0,0 +1,650 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : hub.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Create/tear down conversation topics, route messages between topics.
*
*****************************************************************************/
package main
import (
"encoding/json"
"expvar"
"log"
"strconv"
"strings"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
// Subscribe session to topic
type sessionJoin struct {
// Routable (expanded) name of the topic to subscribe to
topic string
// Packet, containing request details
pkt *MsgClientSub
// Session to subscribe to topic
sess *Session
// If this topic was just created
created bool
// If the topic was just loaded
loaded bool
}
// Session wants to leave the topic to topic
type sessionLeave struct {
// Session which initiated the request
sess *Session
// Leave and unsubscribe
unsub bool
}
// Remove topic from hub
type topicUnreg struct {
appid uint32
// Name of the topic to drop
topic string
}
// Request from !pres to another topic to start/stop receiving presence updates
type presSubsReq struct {
id types.Uid
subscribe bool
}
type metaReq struct {
// Routable name of the topic to get info for
topic string
// packet containing details of the Get/Set request
pkt *ClientComMessage
// Session which originated the request
sess *Session
// what is being requested, constMsgGetInfo, constMsgGetSub, constMsgGetData
what int
}
type Hub struct {
// Topics must be indexed by appid!name
topics map[string]*Topic
// Channel for routing messages between topics, buffered at 1024
route chan *ServerComMessage
// subscribe session to topic, possibly creating a new topic
reg chan *sessionJoin
// Remove topic
unreg chan topicUnreg
// report presence changes
presence chan<- *PresenceRequest
// process get.info requests for topic not subscribed to
meta chan *metaReq
// Exported counter of live topics
topicsLive *expvar.Int
}
func (h *Hub) topicKey(appid uint32, name string) string {
return strconv.FormatInt(int64(appid), 32) + "!" + name
}
func (h *Hub) topicGet(appid uint32, name string) *Topic {
return h.topics[h.topicKey(appid, name)]
}
func (h *Hub) topicPut(appid uint32, name string, t *Topic) {
h.topics[h.topicKey(appid, name)] = t
}
func (h *Hub) topicDel(appid uint32, name string) {
delete(h.topics, h.topicKey(appid, name))
}
func newHub() *Hub {
var h = &Hub{
topics: make(map[string]*Topic),
// this needs to be buffered - hub generates invites and adds them to this queue
route: make(chan *ServerComMessage, 1024),
reg: make(chan *sessionJoin),
unreg: make(chan topicUnreg),
presence: make(chan *PresenceRequest),
meta: make(chan *metaReq, 32),
topicsLive: new(expvar.Int)}
expvar.Publish("LiveTopics", h.topicsLive)
go h.run()
return h
}
func (h *Hub) run() {
log.Println("Hub started")
for {
select {
case sreg := <-h.reg:
// Handle a subscription request:
// 1. Init topic
// 1.1 If a new topic is requested, create it
// 1.2 If a new subscription to an existing topic is requested:
// 1.2.1 check if topic is already loaded
// 1.2.2 if not, load it
// 1.2.3 if it cannot be loaded (not found), fail
// 2. Check access rights and reject, if appropriate
// 3. Attach session to the topic
t := h.topicGet(sreg.sess.appid, sreg.topic) // is the topic already loaded?
if t == nil {
// Topic does not exist or not loaded
go topicInit(sreg, h)
} else {
// Topic found.
// Topic will check access rights and send appropriate {ctrl}
t.reg <- sreg
}
case msg := <-h.route:
// This is a message from a connection not subscribed to topic
// Route incoming message to topic if topic permits such routing
timestamp := time.Now().UTC().Round(time.Millisecond)
if dst := h.topicGet(msg.appid, msg.rcptto); dst != nil {
// Everything is OK, sending packet to known topic
log.Printf("Hub. Sending message to '%s'", dst.name)
simpleSender(dst.broadcast, msg)
} else {
if msg.Data != nil {
// Normally the message is persisted at the topic. If the topic is offline,
// persist message here. The only case of sending to offline topics is invites/info to 'me'
// 'me' must receive them, so ignore access sesstings
// TODO(gene): save message for later delivery
if err := store.Messages.Save(msg.appid, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: msg.Data.Timestamp},
Topic: msg.rcptto,
From: types.ParseUserId(msg.Data.From).String(),
Content: msg.Data.Content}); err != nil {
simpleByteSender(msg.akn, ErrUnknown(msg.id, msg.Data.Topic, timestamp))
return
}
// TODO(gene): validate topic name, discarding invalid topics
log.Printf("Hub. Topic '%d.%s' is unknown or offline", msg.appid, msg.rcptto)
for tt, _ := range h.topics {
log.Printf("Hub contains topic '%s'", tt)
}
simpleByteSender(msg.akn, NoErrAccepted(msg.id, msg.rcptto, timestamp))
}
}
case meta := <-h.meta:
log.Println("hub.meta: got message")
// Request for topic info from a user who is not subscribed to the topic
if dst := h.topicGet(meta.sess.appid, meta.topic); dst != nil {
// If topic is already in memory, pass request to topic
log.Println("hub.meta: topic already in memory")
dst.meta <- meta
} else if meta.pkt.Get != nil {
// If topic is not in memory, fetch requested info from DB and reply here
log.Println("hub.meta: topic NOT in memory")
go replyTopicInfoBasic(meta.sess, meta.topic, meta.pkt.Get)
}
case unreg := <-h.unreg:
if t := h.topicGet(unreg.appid, unreg.topic); t != nil {
h.topicDel(unreg.appid, unreg.topic)
h.topicsLive.Add(-1)
t.sessions = nil
close(t.reg)
close(t.unreg)
close(t.broadcast)
if t.pres != nil {
close(t.pres)
}
}
case <-time.After(IDLETIMEOUT):
}
}
}
// topicInit reads an existing topic from database or creates a new topic
func topicInit(sreg *sessionJoin, h *Hub) {
var t *Topic
timestamp := time.Now().UTC().Round(time.Millisecond)
t = &Topic{name: sreg.topic,
original: sreg.pkt.Topic,
appid: sreg.sess.appid,
sessions: make(map[*Session]bool),
broadcast: make(chan *ServerComMessage, 256),
reg: make(chan *sessionJoin, 32),
unreg: make(chan *sessionLeave, 32),
meta: make(chan *metaReq, 32),
perUser: make(map[types.Uid]perUserData),
}
// Request to load a me topic. The topic must exist
if t.original == "me" {
log.Println("hub: loading me topic")
t.cat = TopicCat_Me
user, err := store.Users.Get(t.appid, sreg.sess.uid)
if err != nil {
log.Println("hub: cannot load user object for 'me'='" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.original, timestamp))
return
}
if err = t.loadSubscriptions(); err != nil {
log.Println("hub: cannot load subscritions for '" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.original, timestamp))
return
}
// 'me' has no owner
// t.owner = sreg.sess.uid
// t.accessAuth = types.ModeBanned
// t.accessAnon = types.ModeBanned
t.public = user.Public
t.created = user.CreatedAt
t.updated = user.UpdatedAt
//t.lastMessage = time.Time{}
// Request to create a new p2p topic, then attach to it
} else if strings.HasPrefix(t.original, "usr") {
log.Println("hub: new p2p topic")
t.cat = TopicCat_P2P
// t.owner is blank for p2p topics
// Ensure that other users are automatically rejected
t.accessAuth = types.ModeBanned
t.accessAnon = types.ModeBanned
// modeWant is either default or given in Init, modeGiven are defined in the User object
userData := perUserData{modeWant: types.ModeP2P, modeGiven: types.ModeNone}
if sreg.pkt.Init != nil {
// t.public is not used for p2p topics since each user get a different public
userData.private = sreg.pkt.Init.Private
// Init.DefaultAcs and Init.Public are ignored for p2p topics
}
// Custom default access levels set in sreg.pkt.Init.DefaultAcs are ignored
// User may set non-default access to topic
if sreg.pkt.Sub != nil && sreg.pkt.Sub.Mode != "" {
if err := userData.modeWant.UnmarshalText([]byte(sreg.pkt.Sub.Mode)); err != nil {
log.Println("hub: invalid access mode for topic '" + t.name + "': '" + sreg.pkt.Sub.Mode + "'")
}
}
userId1 := sreg.sess.uid
userId2 := types.ParseUserId(t.original)
user1 := &types.Subscription{
User: userId1.String(),
Topic: t.name,
ModeWant: userData.modeWant,
ModeGiven: userData.modeGiven,
Private: userData.private}
user2 := &types.Subscription{
User: userId2.String(),
Topic: t.name,
ModeWant: types.ModeNone,
ModeGiven: types.ModeP2P,
Private: nil}
// CreateP2P will set user.Public
err := store.Topics.CreateP2P(t.appid, user1, user2)
if err != nil {
log.Println("hub: databse error in creating subscriptions '" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.name, timestamp))
return
}
t.created = user1.CreatedAt
t.updated = user1.UpdatedAt
userData.public = user2.GetPublic()
userData.modeWant = user1.ModeWant
userData.modeGiven = user1.ModeGiven
userData.lastSeenTag = user1.LastSeen
t.perUser[userId1] = userData
t.perUser[userId2] = perUserData{
public: user1.GetPublic(),
modeWant: user2.ModeWant,
modeGiven: user2.ModeGiven,
lastSeenTag: user2.LastSeen}
t.original = t.name
sreg.created = true
// Load an existing p2p topic
} else if strings.HasPrefix(t.name, "p2p") {
log.Println("hub: existing p2p topic")
t.cat = TopicCat_P2P
// Load the topic object
stopic, err := store.Topics.Get(sreg.sess.appid, t.name)
if err != nil {
log.Println("hub: error while loading topic '" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.original, timestamp))
return
} else if stopic == nil {
log.Println("hub: topic '" + t.name + "' does not exist")
sreg.sess.QueueOut(ErrTopicNotFound(sreg.pkt.Id, t.original, timestamp))
return
}
subs, err := store.Topics.GetSubs(t.appid, t.name, nil)
if err != nil {
log.Println("hub: cannot load subscritions for '" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.name, timestamp))
return
} else if len(subs) != 2 {
log.Println("hub: invalid number of subscriptions for '" + t.name + "'")
sreg.sess.QueueOut(ErrTopicNotFound(sreg.pkt.Id, t.name, timestamp))
return
}
// t.owner no valid owner for p2p topics, leave blank
// Ensure that other users are automatically rejected
t.accessAuth = types.ModeBanned
t.accessAnon = types.ModeBanned
// t.public is not used for p2p topics since each user gets a different public
t.created = stopic.CreatedAt
t.updated = stopic.UpdatedAt
if stopic.LastMessageAt != nil {
t.lastMessage = *stopic.LastMessageAt
}
for i := 0; i < 2; i++ {
uid := types.ParseUid(subs[i].User)
t.perUser[uid] = perUserData{
// Based on other user
public: subs[(i+1)%2].GetPublic(),
private: subs[i].Private,
lastSeenTag: subs[i].LastSeen,
modeWant: subs[i].ModeWant,
modeGiven: subs[i].ModeGiven}
}
// Processing request to create a new generic (group) topic:
} else if t.original == "new" {
log.Println("hub: new group topic")
t.cat = TopicCat_Grp
// Generic topics have parameters stored in the topic object
t.owner = sreg.sess.uid
t.accessAuth = DEFAULT_AUTH_ACCESS
t.accessAnon = DEFAULT_ANON_ACCESS
// Owner/creator gets full access to topic
userData := perUserData{modeGiven: types.ModeFull}
// User sent initialization parameters
if sreg.pkt.Init != nil {
t.public = sreg.pkt.Init.Public
userData.private = sreg.pkt.Init.Private
// set default access
if sreg.pkt.Init.DefaultAcs != nil {
t.accessAuth, t.accessAnon = parseTopicAccess(sreg.pkt.Init.DefaultAcs, t.accessAuth, t.accessAnon)
}
}
// Owner/creator may restrict own access to topic
if sreg.pkt.Sub == nil || sreg.pkt.Sub.Mode == "" {
userData.modeWant = types.ModeFull
} else {
if err := userData.modeWant.UnmarshalText([]byte(sreg.pkt.Sub.Mode)); err != nil {
log.Println("hub: invalid access mode for topic '" + t.name + "': '" + sreg.pkt.Sub.Mode + "'")
}
}
t.perUser[t.owner] = userData
t.created = timestamp
t.updated = timestamp
//t.lastMessage = time.Time{}
stopic := &types.Topic{
ObjHeader: types.ObjHeader{CreatedAt: timestamp},
Name: sreg.topic,
Access: types.DefaultAccess{Auth: t.accessAuth, Anon: t.accessAnon},
Public: t.public}
// store.Topics.Create will add a subscription record for the topic creator
stopic.GiveAccess(t.owner, userData.modeWant, userData.modeGiven)
err := store.Topics.Create(sreg.sess.appid, stopic, t.owner, t.perUser[t.owner].private)
if err != nil {
log.Println("hub: cannot save new topic '" + t.name + "' (" + err.Error() + ")")
// Error sent on "new" topic
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.original, timestamp))
return
}
t.original = t.name // keeping 'new' as original has no value to the client
sreg.created = true
} else {
log.Println("hub: existing group topic")
t.cat = TopicCat_Grp
// TODO(gene): check and validate topic name
stopic, err := store.Topics.Get(sreg.sess.appid, t.name)
if err != nil {
log.Println("hub: error while loading topic '" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.original, timestamp))
return
} else if stopic == nil {
log.Println("hub: topic '" + t.name + "' does not exist")
sreg.sess.QueueOut(ErrTopicNotFound(sreg.pkt.Id, t.original, timestamp))
return
}
if err = t.loadSubscriptions(); err != nil {
log.Println("hub: cannot load subscritions for '" + t.name + "' (" + err.Error() + ")")
sreg.sess.QueueOut(ErrUnknown(sreg.pkt.Id, t.original, timestamp))
return
}
// t.owner is set by loadSubscriptions
t.accessAuth = stopic.Access.Auth
t.accessAnon = stopic.Access.Anon
t.public = stopic.Public
t.created = stopic.CreatedAt
t.updated = stopic.UpdatedAt
if stopic.LastMessageAt != nil {
t.lastMessage = *stopic.LastMessageAt
}
}
log.Println("hub: topic created or loaded: " + t.name)
h.topicPut(t.appid, t.name, t)
h.topicsLive.Add(1)
go t.run(h)
sreg.loaded = true
// Topic will check access rights, send invite to p2p user, send {ctrl} message to the initiator session
t.reg <- sreg
}
// loadSubscriptions loads topic subscribers, sets topic owner & lastMessage
func (t *Topic) loadSubscriptions() error {
subs, err := store.Topics.GetSubs(t.appid, t.name, nil)
if err != nil {
return err
}
for _, sub := range subs {
uid := types.ParseUid(sub.User)
t.perUser[uid] = perUserData{
private: sub.Private,
lastSeenTag: sub.LastSeen, // could be nil
modeWant: sub.ModeWant,
modeGiven: sub.ModeGiven}
if sub.ModeGiven&sub.ModeWant&types.ModeOwner != 0 {
log.Printf("hub.loadSubscriptions: %s set owner to %s", t.name, uid.String())
t.owner = uid
}
// For 'me' topic:
if sub.LastMessageAt != nil && !sub.LastMessageAt.IsZero() {
t.lastMessage = *sub.LastMessageAt
log.Printf("hub.loadSubscriptions: topic %s set lastMessage to %s", t.name, t.lastMessage.String())
}
}
return nil
}
// replyTopicInfoBasic loads minimal topic Info when the requester is not subscribed to the topic
func replyTopicInfoBasic(sess *Session, topic string, get *MsgClientGet) {
log.Printf("hub.replyTopicInfoBasic: topic %s", topic)
now := time.Now().UTC().Round(time.Millisecond)
info := &MsgTopicInfo{}
if strings.HasPrefix(topic, "grp") {
stopic, err := store.Topics.Get(sess.appid, topic)
if err == nil {
info.CreatedAt = &stopic.CreatedAt
info.UpdatedAt = &stopic.UpdatedAt
info.LastMessage = stopic.LastMessageAt
info.Public = stopic.Public
} else {
log.Printf("hub.replyTopicInfoBasic: sending error 1")
simpleByteSender(sess.send, ErrUnknown(get.Id, get.Topic, now))
return
}
} else {
// 'me' and p2p topics
var uid types.Uid
if strings.HasPrefix(topic, "usr") {
// User specified as usrXXX
uid = types.ParseUserId(topic)
} else if strings.HasPrefix(topic, "p2p") {
// User specified as p2pXXXYYY
uid1, uid2, _ := types.ParseP2P(topic)
if uid1 == sess.uid {
uid = uid2
} else if uid2 == sess.uid {
uid = uid1
}
}
if uid.IsZero() {
log.Printf("hub.replyTopicInfoBasic: sending error 2")
simpleByteSender(sess.send, ErrMalformed(get.Id, get.Topic, now))
return
}
suser, err := store.Users.Get(sess.appid, uid)
if err == nil {
info.CreatedAt = &suser.CreatedAt
info.UpdatedAt = &suser.UpdatedAt
info.Public = suser.Public
} else {
log.Printf("hub.replyTopicInfoBasic: sending error 3")
simpleByteSender(sess.send, ErrUnknown(get.Id, get.Topic, now))
return
}
}
log.Printf("hub.replyTopicInfoBasic: sending info -- OK")
simpleByteSender(sess.send, &ServerComMessage{
Meta: &MsgServerMeta{Id: get.Id, Topic: get.Topic, Timestamp: &now, Info: info}})
}
// Parse topic access parameters
func parseTopicAccess(acs *MsgDefaultAcsMode, defAuth, defAnon types.AccessMode) (auth, anon types.AccessMode) {
auth, anon = defAuth, defAnon
if acs.Auth != "" {
if err := auth.UnmarshalText([]byte(acs.Auth)); err != nil {
log.Println("hub: invalid default auth access mode '" + acs.Auth + "'")
}
}
if acs.Anon != "" {
if err := anon.UnmarshalText([]byte(acs.Anon)); err != nil {
log.Println("hub: invalid default anon access mode '" + acs.Anon + "'")
}
}
return
}
// simpleSender attempts to send a message to a connection, time out is 1 second
func simpleSender(sendto chan<- *ServerComMessage, msg *ServerComMessage) {
if sendto == nil {
return
}
select {
case sendto <- msg:
case <-time.After(time.Second):
log.Println("simpleSender: timeout")
}
}
// simpleByteSender attempts to send a JSON to a connection, time out is 1 second
func simpleByteSender(sendto chan<- []byte, msg *ServerComMessage) {
if sendto == nil {
return
}
data, _ := json.Marshal(msg)
select {
case sendto <- data:
case <-time.After(time.Second):
log.Println("simpleByteSender: timeout")
}
}

161
server/lphandler.go Normal file
View File

@ -0,0 +1,161 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : lphandler.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Handler of long polling clients (see also wshandler for web sockets)
*
*****************************************************************************/
package main
import (
"encoding/json"
"io/ioutil"
"log"
"net/http"
"time"
)
func lp_writePkt(wrt http.ResponseWriter, pkt *ServerComMessage) error {
data, _ := json.Marshal(pkt)
_, err := wrt.Write(data)
return err
}
func (sess *Session) writeOnce() {
// Next call may change wrt, save it here
wrt := sess.wrt
notifier, _ := wrt.(http.CloseNotifier)
closed := notifier.CloseNotify()
select {
case msg, ok := <-sess.send:
if !ok {
log.Println("writeOnce: reading from a closed channel")
} else if _, err := wrt.Write(msg); err != nil {
log.Println("sess.writeOnce: " + err.Error())
sess.wrt = nil
}
case <-closed:
log.Println("conn.writeOnce: connection closed by peer")
sess.wrt = nil
case <-time.After(pingPeriod):
// just write an empty packet on timeout
if _, err := wrt.Write([]byte{}); err != nil {
log.Println("sess.writeOnce: timout/" + err.Error())
}
}
}
func (sess *Session) readOnce(req *http.Request) {
if raw, err := ioutil.ReadAll(req.Body); err == nil {
sess.dispatch(raw)
} else {
log.Println("longPoll: " + err.Error())
}
}
// serveLongPoll handles long poll connections when WebSocket is not available
// Connection could be without sid or with sid:
// - if sid is empty, create session, expect a login in the same request, respond and close
// - if sid is not empty and there is an initialized session, payload is optional
// - if no payload, perform long poll
// - if payload exists, process it and close
// - if sid is not empty but there is no session, report an error
func serveLongPoll(wrt http.ResponseWriter, req *http.Request) {
var appid uint32
// Use lowest common denominator - this is a legacy handler after all
wrt.Header().Set("Content-Type", "text/plain")
enc := json.NewEncoder(wrt)
if appid, _ = checkApiKey(getApiKey(req)); appid == 0 {
wrt.WriteHeader(http.StatusForbidden)
enc.Encode(
&ServerComMessage{Ctrl: &MsgServerCtrl{
Code: http.StatusForbidden,
Text: "Valid API key is required"}})
return
}
// TODO(gene): should it be configurable?
// Currently any domain is allowed to get data from the chat server
wrt.Header().Set("Access-Control-Allow-Origin", "*")
// Ensure the response is not cached
wrt.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") // HTTP 1.1
wrt.Header().Set("Pragma", "no-cache") // HTTP 1.0
wrt.Header().Set("Expires", "0") // Proxies
// TODO(gene): respond differently to valious HTTP methods
log.Printf("HTTP %s %s?%s from '%s' %d bytes", req.Method,
req.URL.Path, req.URL.RawQuery, req.RemoteAddr, req.ContentLength)
// Get session id
sid := req.FormValue("sid")
if sid == "" {
sess := globals.sessionStore.Create(wrt, appid)
log.Println("longPoll: new session created, sid=", sess.sid)
wrt.WriteHeader(http.StatusCreated)
enc.Encode(
&ServerComMessage{Ctrl: &MsgServerCtrl{
Code: http.StatusCreated,
Text: http.StatusText(http.StatusCreated),
Params: map[string]interface{}{"sid": sess.sid, "ver": VERSION},
Timestamp: time.Now().UTC().Round(time.Millisecond)}})
// Any payload is ignored
return
}
sess := globals.sessionStore.Get(sid)
if sess == nil {
log.Println("longPoll: invalid or expired session id ", sid)
wrt.WriteHeader(http.StatusForbidden)
enc.Encode(
&ServerComMessage{Ctrl: &MsgServerCtrl{
Code: http.StatusForbidden,
Text: "Invalid or expired session id"}})
return
}
sess.wrt = wrt
sess.remoteAddr = req.RemoteAddr
if req.ContentLength > 0 {
// Got payload. Process it and return right away
sess.readOnce(req)
return
}
// Wait for data, write it to the connection or timeout
sess.writeOnce()
}

112
server/main.go Normal file
View File

@ -0,0 +1,112 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : main.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Setup & initialization.
*
*****************************************************************************/
package main
import (
_ "expvar"
"flag"
_ "github.com/tinode/chat/server/db/rethinkdb"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"log"
"net/http"
"os"
"runtime"
"time"
)
const (
IDLETIMEOUT = time.Second * 55 // Terminate session after this timeout.
TOPICTIMEOUT = time.Minute * 5 // Tear down topic after this period of silence.
// API version
VERSION = "0.4"
// Lofetime of authentication tokens
TOKEN_LIFETIME_DEFAULT = time.Hour * 12 // 12 hours
TOKEN_LIFETIME_MAX = time.Hour * 24 * 7 // 1 week
DEFAULT_AUTH_ACCESS = types.ModePublic
DEFAULT_ANON_ACCESS = types.ModeNone
)
var globals struct {
hub *Hub
sessionStore *SessionStore
}
func main() {
// For serving static content
path, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
log.Printf("Home dir: '%s'", path)
log.Printf("Server started with processes: %d",
runtime.GOMAXPROCS(runtime.NumCPU()))
var listenOn = flag.String("bind", "127.0.0.1:8080",
"111.22.33.44:80 - IP address/host name and port number to listen on")
var dbsource = flag.String("db", "", "Data source name and configuration")
flag.Parse()
err = store.Open(*dbsource)
if err != nil {
log.Fatal("failed to connect to DB: ", err)
}
defer store.Close()
globals.sessionStore = NewSessionStore(2 * time.Hour)
globals.hub = newHub()
// Static content from http://<host>/x/: just read files from disk
http.Handle("/x/", http.StripPrefix("/x/", http.FileServer(http.Dir(path+"/static"))))
// Streaming channels
// Handle websocket clients. WS must come up first, so reconnecting clients won't fall back to LP
http.HandleFunc("/v0/channels", serveWebSocket)
// Handle long polling clients
http.HandleFunc("/v0/channels/lp", serveLongPoll)
log.Printf("Listening on [%s]", *listenOn)
log.Fatal(http.ListenAndServe(*listenOn, nil))
}
func getApiKey(req *http.Request) string {
apikey := req.FormValue("apikey")
if apikey == "" {
apikey = req.Header.Get("X-Tinode-APIKey")
}
return apikey
}

370
server/presence.go Normal file
View File

@ -0,0 +1,370 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : presence.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************/
/******************************************************************************
*
* Description :
*
* Handler of presence notifications
*
* User subscribes to topic !pres to receive online/offline notifications
* - immediately after subscription get a list of contacts with their current statuses
* - receive live status updates
* - Params contain "filter" - list of base64-encoded GUIDs to be notified about
* -- if params["filter"] == "!contacts", then contacts are loaded from DB
*
* Publishing to !pres: updates filter:
* - params["filter_add"] == list of ids to add
* - params["filter_rem"] == list of ids to remove
* -- such changes are persisted to db
*
* Online/Offline notifications come from the !pres topic as Data packets
*
* User subscribes to !me to announce his online presence
* User publishes to !me to update his status information ("DND", "Away" or an object)
*
*****************************************************************************/
package main
import (
"github.com/tinode/chat/server/store/types"
"log"
)
type UserPresence struct {
id types.Uid
// Publisher to !pres (subscribed to !me), regardless of the number of readers
// Equivalent to user being online
publisher bool
// Subscriber of !pres channel, regardless of the number of contacts
subscriber bool
// Generalized status line, like a string "DND", "Away", or something more complex
status interface{}
// List of users who are subscribed to this user, publisher == true
pushTo map[types.Uid]bool
// Index (User -> subscribed to), subscribed == true
attachTo map[types.Uid]bool
}
const (
ActionOnline = iota // user subscribed to !me
ActionOffline // user unsubscribed from !me
ActionSubscribed // user subscribed to !pres
ActionUnsubscribed // unsubscribed from !pres
ActionStatus // user updated status
ActionFinish // the system is shutting down
)
type PresenceRequest struct {
AppId uint32
Id types.Uid
Action int
// Session being subscribed or unsubscribed
Sess *Session
// List of UIDs + to subscribe to or unsubscribe from (or block)
Contacts []types.TopicAccess
// Status line like "Away" or "DND"
Status interface{}
}
// Add another subscriber to user
func (up *UserPresence) attachReader(id types.Uid) {
up.pushTo[id] = true
}
// Remove subscriber from user
func (up *UserPresence) detachReader(id types.Uid) {
delete(up.pushTo, id)
}
// Save subscription information
// User is not subscribed or unsubscribed here
func (up *UserPresence) subscribeTo(id types.Uid) {
up.attachTo[id] = true
}
// Remove subscription information
// User is not subscribed or unsubscribed here
func (up *UserPresence) unsubscribeFrom(id types.Uid) {
// This is expected to panicif up.attachTo is nil
delete(up.attachTo, id)
}
// Known status publishers and subscribers, online and offline
// - publisher (online or offline) -> some online users want to know his status
// - subscriber (online only) -> wants to receive status updates
type presenceIndex struct {
index map[types.Uid]*UserPresence
action chan *PresenceRequest
}
// Initialize presence index and return channel for receiving updates
func InitPresenceHandler() chan<- *PresenceRequest {
pi := presenceIndex{
index: make(map[types.Uid]*UserPresence),
action: make(chan *PresenceRequest, 64)}
go presenceHandler(pi)
return pi.action
}
func presenceHandler(pi presenceIndex) {
for {
select {
case msg := <-pi.action:
me := pi.index[msg.Id]
switch msg.Action {
case ActionOnline:
if me == nil {
// The user is not subscribed to !pres, no one is subscribed to him
pi.index[msg.Id] = &UserPresence{id: msg.Id, status: msg.Status, publisher: true}
log.Println("Presence: user came online but no one cares")
} else {
pi.Online(msg.AppId, me, msg.Status)
log.Println("Presence: user came online, updated listeners")
}
log.Println("Presence: Online done")
case ActionOffline:
// Will panic if me == nil
pi.Offline(msg.AppId, me)
log.Println("Presence: Offline done")
case ActionSubscribed:
if me == nil {
// The user is not subscribed to either !pres or !me yet
me = &UserPresence{id: msg.Id}
pi.index[msg.Id] = me
}
pi.SubPresence(me, msg.Sess, msg.Contacts)
log.Println("Presence: Subscribed done")
case ActionUnsubscribed:
// Will panic if me == nil
pi.UnsubPresence(me, msg.Contacts)
log.Println("Presence: Unsibscribed done")
case ActionStatus:
if me != nil {
// User could be offline, i.e. status updated through REST API
pi.Status(msg.AppId, me, msg.Status)
}
log.Println("Presence: Status done")
case ActionFinish:
log.Println("presence: finished")
return
}
}
}
}
// Attach user to publisher
// Subscriber (user.id==id) will start receiving presence notifications from publisher (user.id==to)
func (pi presenceIndex) attach(to, id types.Uid) {
pub := pi.index[to]
if pub == nil {
// User is not online yet either
pub = &UserPresence{id: to}
pi.index[to] = pub
}
if pub.pushTo == nil {
// User is offline and no one was subscribed to him before
pub.pushTo = make(map[types.Uid]bool)
}
// Don't set pub.publisher = true here, the user does not really publish anything unless he is also online
pub.attachReader(id)
}
// Detach subscriber from publisher (i.e. subscriber went offline)
// Subscriber (id) will no longer receive presence notifications from publisher (from)
// If publisher no longer has subscribers and offline, remove him from index
func (pi presenceIndex) detach(from, id types.Uid) {
pub := pi.index[from]
if pub == nil {
// If it happens, it's a bug.
log.Panic("PresenceIndex.Detach called for unknown user")
return
}
pub.detachReader(id)
if len(pub.pushTo) == 0 {
pub.pushTo = nil
// Publisher is offline and has no subscribers
if !pub.publisher && !pub.subscriber {
delete(pi.index, from)
}
}
}
type SimpleOnline struct {
Who string `json:"who"`
Online bool `json:"online"`
Status interface{} `json:"status,omitempty"`
Params interface{} `json:"params,omitempty"`
}
// User subscribed to "!pres" or updated a list of contacts
// Attach him to pushTo of other users, even if they are currently offline,
// so when they come online they start pushing to this user
// - id: current user
// - attachTo: list of users to subscribe to
// SubPresence may be called multiple time for a single user
func (pi presenceIndex) SubPresence(me *UserPresence, sess *Session, attachTo []types.TopicAccess) {
if len(attachTo) != 0 {
if me.attachTo == nil {
me.attachTo = make(map[types.Uid]bool)
}
// Attach user to pushTo of other users, send their statuses to the newly subscribed user
// Read from my subscriptions
onlineList := make([]SimpleOnline, len(attachTo))
var i int
for _, other := range attachTo {
if (other.Given & other.Want & types.ModePres) == 0 {
continue
}
// pi.attach will create a user to attach to if needed
uid := types.ParseUid(other.User)
pi.attach(uid, me.id)
me.subscribeTo(uid)
user := pi.index[uid]
i++
//
onlineList[i] = SimpleOnline{Who: user.id.String(), Online: user.publisher, Status: user.status}
}
// Sending to subscribed session only, other sessions of this !pres topic don't care
go simpleByteSender(sess.send, &ServerComMessage{Data: &MsgServerData{Topic: "!pres", Content: onlineList}})
}
me.subscriber = true
}
// User subscribed to "!me" announcing online presence
// Publish user's online presence to subscribers
// - id: user's id
// - extra: some extra information user wants to publish, like "DND", "AWAY", {emoticon, "I'm going to the movies"}
func (pi presenceIndex) Online(appid uint32, me *UserPresence, extra interface{}) {
if len(me.pushTo) > 0 {
if extra == nil && me.status != nil {
extra = me.status
} else {
me.status = extra
}
// Publish update to subscribers
online := SimpleOnline{Who: me.id.String(), Online: true, Status: extra}
update := &MsgServerData{Topic: "!pres",
Content: []SimpleOnline{online}}
for guid, _ := range me.pushTo {
globals.hub.route <- &ServerComMessage{Data: update, appid: appid, rcptto: "!pres:" + guid.String()}
log.Println("Sent online status to ", guid.String())
}
}
me.publisher = true
}
// Either !pres topic was deleted because everyone unsubscribed, or user just deleted some contacts
// Remove him from pushTo of other users
// FIXME(gene): handle the following case:
// 1. User has online subscribers
// 2. User goes offline, but because he has subscribers he stays in index
// 3. All subscribers unsub from !pres.
// 4. The user is offline with no subscribers, but record stays in index
func (pi presenceIndex) UnsubPresence(me *UserPresence, detachFrom []types.TopicAccess) {
if me.subscriber {
// Remove user from pushTo of other users
if detachFrom != nil {
// Handle partial unsubscribe
for _, other := range detachFrom {
uid := types.ParseUid(other.User)
delete(me.attachTo, uid)
pi.detach(uid, me.id)
}
} else if len(me.attachTo) != 0 {
// unsubscribing from all
for uid, _ := range me.attachTo {
pi.detach(uid, me.id)
}
me.subscriber = false
}
if len(me.attachTo) == 0 {
me.attachTo = nil
}
}
if !me.publisher && !me.subscriber && len(me.pushTo) == 0 {
// User is offline with no subscribers
delete(pi.index, me.id)
}
}
// User unsubscribed from !me (may still be subscribed to !pres
// Announce his disappearance to subscribers
func (pi presenceIndex) Offline(appid uint32, me *UserPresence) {
if me.publisher {
// Let subscribers know that user went offline
online := SimpleOnline{Who: me.id.String(), Online: false}
data := &MsgServerData{Topic: "!pres", Content: []SimpleOnline{online}}
for uid, _ := range me.pushTo {
globals.hub.route <- &ServerComMessage{Data: data, appid: appid, rcptto: "!pres:" + uid.String()}
}
me.publisher = false
}
if len(me.pushTo) == 0 && !me.subscriber {
// Also, user is not subscribed to anyone, remove him from index
delete(pi.index, me.id)
}
}
// User updated his status
func (pi presenceIndex) Status(appid uint32, me *UserPresence, status interface{}) {
me.status = status
if me.publisher && len(me.pushTo) > 0 {
data := &MsgServerData{Topic: "!pres",
Content: map[string]interface{}{"who": me.id.String(), "status": status}}
for uid, _ := range me.pushTo {
globals.hub.route <- &ServerComMessage{Data: data, appid: appid, rcptto: "!pres:" + uid.String()}
}
}
}

607
server/session.go Normal file
View File

@ -0,0 +1,607 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : session.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Handling of user sessions/connections. One user may have multiple sesions.
* Each session may handle multiple topics
*
*****************************************************************************/
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
NONE = iota
WEBSOCK
LPOLL
TAG_UNDEF = "-"
)
/*
A single WS connection or a long poll session. A user may have multiple
connections (control connection, multiple simultaneous group chat)
*/
type Session struct {
// protocol - NONE (unset) or WEBSOCK, LPOLL
proto int
// Set only for websockets
ws *websocket.Conn
// Set only for Long Poll sessions
wrt http.ResponseWriter
// IP address of the client. For long polling this is the IP of the last poll
remoteAddr string
// Application ID
appid uint32
// Client instance tag, a string provived by an authenticated client for managing client-side cache
tag string
// ID of the current user or 0
uid types.Uid
// Time when the session was last touched
lastTouched time.Time
// outbound mesages, buffered
send chan []byte
// Map of topic subscriptions, indexed by topic name
subs map[string]*Subscription
// Session ID for long polling
sid string
// Needed for long polling
rw sync.RWMutex
}
// Mapper of sessions to topics
type Subscription struct {
// Channel to communicate with the topic, copy of Topic.broadcast
broadcast chan<- *ServerComMessage
// Session sends a signal to Topic when this session is unsubscribed
// This is a copy of Topic.unreg
done chan<- *sessionLeave
// Channel to send {meta} requests, copy of Topic.meta
meta chan<- *metaReq
}
func (s *Session) close() {
if s.proto == WEBSOCK {
s.ws.Close()
}
}
func (s *Session) writePkt(pkt *ServerComMessage) error {
data, _ := json.Marshal(pkt)
switch s.proto {
case WEBSOCK:
return ws_write(s.ws, websocket.TextMessage, data)
case LPOLL:
_, err := s.wrt.Write(data)
return err
default:
return errors.New("invalid session")
}
}
// TODO(gene): unify simpleByteSender and QueueOut
// QueueOut attempts to send a SCM to a session; if the send buffer is full, time out is 1 second
func (s *Session) QueueOut(msg *ServerComMessage) {
data, _ := json.Marshal(msg)
select {
case s.send <- data:
case <-time.After(time.Second):
log.Println("session.queueOut: timeout")
}
}
/*
// simpleByteSender attempts to send a JSON to a connection, time out is 1 second
func simpleByteSender(sendto chan<- []byte, msg *ServerComMessage) {
data, _ := json.Marshal(msg)
select {
case sendto <- data:
case <-time.After(time.Second):
log.Println("simpleByteSender: timeout")
}
}
*/
func (s *Session) dispatch(raw []byte) {
var msg ClientComMessage
log.Printf("Session.dispatch got '%s' from '%s'", raw, s.remoteAddr)
timestamp := time.Now().UTC().Round(time.Millisecond)
if err := json.Unmarshal(raw, &msg); err != nil {
// Malformed message
log.Println("Session.dispatch: " + err.Error())
s.QueueOut(ErrMalformed("", "", timestamp))
return
}
msg.from = s.uid.UserId()
msg.timestamp = timestamp
// Locking-unlocking is needed for long polling.
// Should not affect performance
s.rw.Lock()
defer s.rw.Unlock()
switch {
case msg.Pub != nil:
s.publish(&msg)
log.Println("dispatch: Pub done")
case msg.Sub != nil:
s.subscribe(&msg)
log.Println("dispatch: Sub done")
case msg.Leave != nil:
s.leave(&msg)
log.Println("dispatch: Leave done")
case msg.Login != nil:
s.login(&msg)
log.Println("dispatch: Login done")
case msg.Get != nil:
s.get(&msg)
log.Println("dispatch: Get." + msg.Get.What + " done")
case msg.Set != nil:
s.set(&msg)
log.Println("dispatch: Set." + msg.Set.What + " done")
case msg.Del != nil:
s.del(&msg)
log.Println("dispatch: Del." + msg.Del.What + " done")
case msg.Acc != nil:
s.acc(&msg)
log.Println("dispatch: Acc done")
default:
// Unknown message
s.QueueOut(ErrMalformed("", "", msg.timestamp))
log.Println("Session.dispatch: unknown message")
}
}
// Request to subscribe to a topic
func (s *Session) subscribe(msg *ClientComMessage) {
log.Printf("Sub to '%s' from '%s'", msg.Sub.Topic, msg.from)
var topic, expanded string
if msg.Sub.Topic == "new" {
// Request to create a new named topic
topic = msg.Sub.Topic
expanded = genTopicName()
} else {
var err *ServerComMessage
topic, expanded, err = s.validateTopicName(msg.Sub.Id, msg.Sub.Topic, msg.timestamp)
if err != nil {
s.QueueOut(err)
return
}
}
if _, ok := s.subs[expanded]; ok {
log.Printf("sess.subscribe: already subscribed to '%s'", expanded)
s.QueueOut(InfoAlreadySubscribed(msg.Sub.Id, msg.Sub.Topic, msg.timestamp))
return
}
log.Printf("Sub to '%s' (%s) from '%s' as '%s' -- OK!", expanded, msg.Sub.Topic, msg.from, topic)
globals.hub.reg <- &sessionJoin{topic: expanded, pkt: msg.Sub, sess: s}
// Hub will send Ctrl success/failure packets back to session
}
// Leave/Unsubscribe a topic
func (s *Session) leave(msg *ClientComMessage) {
var reply *ServerComMessage
if msg.Leave.Topic == "" {
s.QueueOut(ErrMalformed(msg.Leave.Id, "", msg.timestamp))
return
}
topic := msg.Leave.Topic
if msg.Leave.Topic == "me" {
topic = s.uid.UserId()
}
if sub, ok := s.subs[topic]; ok {
if msg.Leave.Topic == "me" && msg.Leave.Unsub {
// User should try to unsubscribe from 'me'. Just leaving is fine
reply = ErrPermissionDenied(msg.Leave.Id, msg.Leave.Topic, msg.timestamp)
} else {
sub.done <- &sessionLeave{sess: s, unsub: msg.Leave.Unsub}
reply = NoErr(msg.Leave.Id, msg.Leave.Topic, msg.timestamp)
}
} else {
reply = InfoNotSubscribed(msg.Leave.Id, msg.Leave.Topic, msg.timestamp)
}
s.QueueOut(reply)
}
// Broadcast a message to all topic subscribers
func (s *Session) publish(msg *ClientComMessage) {
// TODo(gene): Check for repeated messages with the same ID
topic, routeTo, err := s.validateTopicName(msg.Pub.Id, msg.Pub.Topic, msg.timestamp)
if err != nil {
s.QueueOut(err)
return
}
data := &ServerComMessage{Data: &MsgServerData{
Topic: topic,
From: msg.from,
Timestamp: msg.timestamp,
Content: msg.Pub.Content},
appid: s.appid, rcptto: routeTo, akn: s.send, id: msg.Pub.Id, timestamp: msg.timestamp}
if sub, ok := s.subs[routeTo]; ok {
// This is a post to a subscribed topic. The message is sent to the topic only
sub.broadcast <- data
} else {
// FIXME(gene): publishing should not be permitted without subscribing first
// This is a message to a topic the current session is not subscribed to. The most common case is
// a message to a user, possbly self.
// The receiving user (rcptto) should see communication on the originator's !usr: topic, the sender on
// receiver's (so the p2p conversation can be aggregated by topic by both parties as each user
// sends/receives on the same topic)
// Global hub sends a Ctrl.202 response back to sender with topic=[receiver's topic]
globals.hub.route <- data
}
}
// Authenticate
func (s *Session) login(msg *ClientComMessage) {
var uid types.Uid
var expires time.Time
var err error
if !s.uid.IsZero() {
s.QueueOut(ErrAlreadyAuthenticated(msg.Login.Id, "", msg.timestamp))
return
} else if msg.Login.Scheme == "" || msg.Login.Scheme == "basic" {
uid, err = store.Users.Login(s.appid, msg.Login.Scheme, msg.Login.Secret)
if err != nil {
// DB error
log.Println(err)
s.QueueOut(ErrUnknown(msg.Login.Id, "", msg.timestamp))
return
} else if uid.IsZero() {
// Invalid login or password
s.QueueOut(ErrAuthFailed(msg.Login.Id, "", msg.timestamp))
return
}
} else if msg.Login.Scheme == "token" {
if uid, expires = checkSecurityToken(msg.Login.Secret); uid.IsZero() {
s.QueueOut(ErrAuthFailed(msg.Login.Id, "", msg.timestamp))
return
}
} else {
s.QueueOut(ErrAuthUnknownScheme(msg.Login.Id, "", msg.timestamp))
return
}
s.uid = uid
s.tag = msg.Login.Tag
if s.tag == "" {
s.tag = TAG_UNDEF
}
expireIn := time.Duration(msg.Login.ExpireIn)
if expireIn <= 0 || expireIn > TOKEN_LIFETIME_MAX {
expireIn = TOKEN_LIFETIME_DEFAULT
}
newExpires := time.Now().Add(expireIn).UTC().Round(time.Second)
if !expires.IsZero() && expires.Before(newExpires) {
newExpires = expires
}
params := map[string]interface{}{
"uid": uid.UserId(),
"expires": newExpires,
"token": makeSecurityToken(uid, expires)}
s.QueueOut(&ServerComMessage{Ctrl: &MsgServerCtrl{
Id: msg.Login.Id,
Code: http.StatusOK,
Text: http.StatusText(http.StatusOK),
Timestamp: msg.timestamp,
Params: params}})
}
// Account creation
func (s *Session) acc(msg *ClientComMessage) {
if msg.Acc.Auth == nil {
s.QueueOut(ErrMalformed(msg.Acc.Id, "", msg.timestamp))
return
} else if len(msg.Acc.Auth) == 0 {
s.QueueOut(ErrAuthUnknownScheme(msg.Acc.Id, "", msg.timestamp))
return
}
if msg.Acc.User == "new" {
// Request to create a new account
for _, auth := range msg.Acc.Auth {
if auth.Scheme == "basic" {
var private interface{}
var user types.User
if msg.Acc.Init != nil {
user.Access.Auth = DEFAULT_AUTH_ACCESS
user.Access.Anon = DEFAULT_ANON_ACCESS
if msg.Acc.Init.DefaultAcs != nil {
if msg.Acc.Init.DefaultAcs.Auth != "" {
user.Access.Auth.UnmarshalText([]byte(msg.Acc.Init.DefaultAcs.Auth))
}
if msg.Acc.Init.DefaultAcs.Anon != "" {
user.Access.Anon.UnmarshalText([]byte(msg.Acc.Init.DefaultAcs.Anon))
}
}
user.Public = msg.Acc.Init.Public
private = msg.Acc.Init.Private
}
_, err := store.Users.Create(s.appid, &user, auth.Scheme, auth.Secret, private)
if err != nil {
if err.Error() == "duplicate credential" {
s.QueueOut(ErrDuplicateCredential(msg.Acc.Id, "", msg.timestamp))
} else {
s.QueueOut(ErrUnknown(msg.Acc.Id, "", msg.timestamp))
}
return
}
reply := NoErrCreated(msg.Acc.Id, "", msg.timestamp)
info := &MsgTopicInfo{
CreatedAt: &user.CreatedAt,
UpdatedAt: &user.UpdatedAt,
DefaultAcs: &MsgDefaultAcsMode{
Auth: user.Access.Auth.String(),
Anon: user.Access.Anon.String()},
Public: user.Public,
Private: private}
reply.Ctrl.Params = map[string]interface{}{
"uid": user.Uid().UserId(),
"info": info,
}
s.QueueOut(NoErr(msg.Acc.Id, "", msg.timestamp))
} else {
s.QueueOut(ErrAuthUnknownScheme(msg.Acc.Id, "", msg.timestamp))
return
}
}
} else if !s.uid.IsZero() {
// Request to change auth of an existing account. Only basic auth is currently supported
for _, auth := range msg.Acc.Auth {
if auth.Scheme == "basic" {
if err := store.Users.ChangeAuthCredential(s.appid, s.uid, auth.Scheme, auth.Secret); err != nil {
s.QueueOut(ErrUnknown(msg.Acc.Id, "", msg.timestamp))
return
}
s.QueueOut(NoErr(msg.Acc.Id, "", msg.timestamp))
} else {
s.QueueOut(ErrAuthUnknownScheme(msg.Acc.Id, "", msg.timestamp))
return
}
}
} else {
// session is not authenticated and this is not an attempt to create a new account
s.QueueOut(ErrPermissionDenied(msg.Acc.Id, "", msg.timestamp))
return
}
}
func (s *Session) get(msg *ClientComMessage) {
log.Println("s.get: processing 'get." + msg.Get.What + "'")
// Validate topic name
original, expanded, err := s.validateTopicName(msg.Get.Id, msg.Get.Topic, msg.timestamp)
if err != nil {
s.QueueOut(err)
return
}
sub, ok := s.subs[expanded]
meta := &metaReq{
topic: expanded,
pkt: msg,
sess: s,
what: parseMsgClientMeta(msg.Get.What)}
if meta.what == 0 {
s.QueueOut(ErrMalformed(msg.Get.Id, original, msg.timestamp))
log.Println("s.get: invalid Get message action: '" + msg.Get.What + "'")
} else if ok {
sub.meta <- meta
} else {
if (meta.what&constMsgMetaData != 0) || (meta.what&constMsgMetaSub != 0) {
log.Println("s.get: invalid Get message action for hub routing: '" + msg.Get.What + "'")
s.QueueOut(ErrPermissionDenied(msg.Get.Id, original, msg.timestamp))
} else {
// Info on a topic not currently subscribed to. Request info from the hub
globals.hub.meta <- meta
}
}
}
func (s *Session) set(msg *ClientComMessage) {
log.Println("s.set: processing 'set." + msg.Set.What + "'")
// Validate topic name
original, expanded, err := s.validateTopicName(msg.Set.Id, msg.Set.Topic, msg.timestamp)
if err != nil {
s.QueueOut(err)
return
}
sub, ok := s.subs[expanded]
meta := &metaReq{
topic: expanded,
pkt: msg,
sess: s,
what: parseMsgClientMeta(msg.Set.What)}
if meta.what == 0 {
s.QueueOut(ErrMalformed(msg.Set.Id, original, msg.timestamp))
log.Println("s.set: invalid Set message action '" + msg.Set.What + "'")
}
if ok {
if (meta.what&constMsgMetaInfo != 0 && msg.Set.Info == nil) ||
(meta.what&constMsgMetaSub != 0 && msg.Set.Sub == nil) {
s.QueueOut(ErrMalformed(msg.Set.Id, original, msg.timestamp))
log.Println("s.set: payload missing for Set action '" + msg.Set.What + "'")
} else {
log.Println("s.set: sending to topic")
sub.meta <- meta
}
} else {
log.Println("s.set: can Set for subscribed topics only")
s.QueueOut(ErrPermissionDenied(msg.Set.Id, original, msg.timestamp))
}
}
func (s *Session) del(msg *ClientComMessage) {
log.Println("s.del: processing 'set." + msg.Del.What + "'")
// Validate topic name
original, expanded, err := s.validateTopicName(msg.Del.Id, msg.Del.Topic, msg.timestamp)
if err != nil {
s.QueueOut(err)
return
}
//var what int
if msg.Del.What == "" || msg.Del.What == "msg" {
//what = constMsgMetaDelMsg
} else if msg.Del.What == "topic" {
//what = constMsgMetaDelTopic
} else {
s.QueueOut(ErrMalformed(msg.Del.Id, original, msg.timestamp))
return
}
_, ok := s.subs[expanded]
//meta := &metaReq{
// topic: expanded,
// pkt: msg,
// sess: s,
// what: what}
if ok {
s.QueueOut(ErrNotImplemented(msg.Del.Id, original, msg.timestamp))
} else {
log.Println("s.del: can Del for subscribed topics only")
s.QueueOut(ErrPermissionDenied(msg.Del.Id, original, msg.timestamp))
}
}
// validateTopicName expands session specific topic name to global name
// Returns
// topic: session-specific topic name the message recepient should see
// routeTo: routable global topic name
// err: *ServerComMessage with an error to return to the sender
func (s *Session) validateTopicName(msgId, topic string, timestamp time.Time) (string, string, *ServerComMessage) {
if topic == "" {
return "", "", ErrMalformed(msgId, "", timestamp)
}
if !strings.HasPrefix(topic, "grp") && s.uid.IsZero() {
// me and p2p topics require authentication
return "", "", ErrAuthRequired(msgId, topic, timestamp)
}
// Topic to route to i.e. rcptto: or s.subs[routeTo]
routeTo := topic
if topic == "me" {
routeTo = s.uid.UserId()
} else if strings.HasPrefix(topic, "usr") {
// packet to a specific user
uid2 := types.ParseUserId(topic)
if uid2.IsZero() {
// Ensure the user id is valid
return "", "", ErrMalformed(msgId, topic, timestamp)
} else if uid2 == s.uid {
// Use 'me' to access self-topic
return "", "", ErrPermissionDenied(msgId, topic, timestamp)
}
routeTo = s.uid.P2PName(uid2)
topic = s.uid.UserId() // but the echo message should come back as uid2.Name()
} else if strings.HasPrefix(topic, "p2p") {
uid1, uid2, err := types.ParseP2P(topic)
if err != nil || uid1.IsZero() || uid2.IsZero() || uid1 == uid2 {
// Ensure the user ids are valid
return "", "", ErrMalformed(msgId, topic, timestamp)
} else if uid1 != s.uid && uid2 != s.uid {
// One can't access someone else's p2p topic
return "", "", ErrPermissionDenied(msgId, topic, timestamp)
}
}
return topic, routeTo, nil
}

109
server/sessionstore.go Normal file
View File

@ -0,0 +1,109 @@
package main
// Management of long polling sessions
import (
"container/list"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/tinode/chat/server/store/types"
)
type sessionStoreElement struct {
key string
val *Session
}
type SessionStore struct {
rw sync.RWMutex
sessions map[string]*list.Element
lru *list.List
lifeTime time.Duration
}
func (ss *SessionStore) Create(conn interface{}, appid uint32) *Session {
var s Session
switch conn.(type) {
case *websocket.Conn:
s.proto = WEBSOCK
s.ws, _ = conn.(*websocket.Conn)
case http.ResponseWriter:
s.proto = LPOLL
s.wrt, _ = conn.(http.ResponseWriter)
default:
s.proto = NONE
}
if s.proto != NONE {
s.subs = make(map[string]*Subscription)
s.send = make(chan []byte, 64) // buffered
}
s.appid = appid
s.lastTouched = time.Now()
s.sid = getRandomString()
s.uid = types.ZeroUid
if s.proto != WEBSOCK {
// Websocket connections are not managed by SessionStore
ss.rw.Lock()
elem := ss.lru.PushFront(&sessionStoreElement{s.sid, &s})
ss.sessions[s.sid] = elem
// Remove expired sessions
expire := s.lastTouched.Add(-ss.lifeTime)
for elem = ss.lru.Back(); elem != nil; elem = ss.lru.Back() {
if elem.Value.(*sessionStoreElement).val.lastTouched.Before(expire) {
ss.lru.Remove(elem)
delete(ss.sessions, elem.Value.(*sessionStoreElement).key)
} else {
break // don't need to traverse further
}
}
ss.rw.Unlock()
}
return &s
}
func (ss *SessionStore) Get(sid string) *Session {
ss.rw.Lock()
defer ss.rw.Unlock()
if elem := ss.sessions[sid]; elem != nil {
ss.lru.MoveToFront(elem)
elem.Value.(*sessionStoreElement).val.lastTouched = time.Now()
return elem.Value.(*sessionStoreElement).val
}
return nil
}
func (ss *SessionStore) Delete(sid string) *Session {
ss.rw.Lock()
defer ss.rw.Unlock()
if elem := ss.sessions[sid]; elem != nil {
ss.lru.Remove(elem)
delete(ss.sessions, sid)
return elem.Value.(*sessionStoreElement).val
}
return nil
}
func NewSessionStore(lifetime time.Duration) *SessionStore {
store := &SessionStore{
sessions: make(map[string]*list.Element),
lru: list.New(),
lifeTime: lifetime,
}
return store
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,736 @@
<!DOCTYPE html>
<!--
Public domain. No warranty is offered or implied; use this code at your own risk.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tinode chat demo v. 0.2</title>
<!-- Compiled and minified bootstrap CSS -->
<link rel="stylesheet" href="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
<!-- Bootstrap js -->
<script src="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
<!-- Tinode javascript library, minified version. Tinode has no extrenal dependencies. -->
<script type="text/javascript" src="../js/tinode-0.4.js"></script>
<style type="text/css">
body, html {
height:100%;
width:100%;
overflow:hidden;
}
body {
padding:1em;
font-size:10pt;
}
pre.log {
font-size:10pt;
line-height:1.25em;
padding:0.25em;
height:40em;
max-height:40em;
}
.scrollable {
overflow-y: scroll;
}
.max-height {
height: 100%;
}
.max-width {
width: 100%;
}
.no-overflow {
overflow: hidden;
}
span.white {
color: white;
}
span.black {
color: black;
}
.dropdown-menu-form {
padding: 0.25em 0.5em 0;
max-height: 10em;
overflow-y: scroll;
}
.contacts {
list-style: none;
margin:0;
padding:0;
max-height:40em;
}
.contacts ul {
margin:0;
padding:0;
}
.contacts li.contact :hover {
background:#f0f0f3;
}
.contacts .contact-body {
padding: 0.5em;
padding-left: 1.75em;
}
.contacts li.contact .pull-left {
margin-top:0.5em;
}
.contacts .online {
color: #093;
}
.contacts .list-group-item-text {
color: #666
}
.chat {
list-style:none;
margin: 0;
padding: 0;
}
#chatFlow {
max-height:30em;
overflow: auto
}
.chat li {
margin-bottom: 0.25em;
padding: 0.25em;
border-bottom: 1px dotted #999;
}
.chat li.left .chat-body {
margin-left: 1.75em;
}
.chat li.right .chat-body {
margin-right: 1.75em;
}
.chat li.right .chat-user {
color:#093;
}
.chat li.left .chat-user {
color:#06C;
}
#topicSubscribers {
x-margin: 0.125em;
padding: 0.125em;
overflow: auto;
background-color: #CCC;
}
</style>
<script type="text/javascript">
$(function() {
// Generate your own API key
var APIKEY = "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"
// Change this URL to point to your messaging server
var ENDPOINT = "http://localhost:8088/"
// Fix for hidden form elements
$("#loginSettingsPanel").hide().removeClass("hidden")
$("#loginError").hide().removeClass("hidden")
$("#contactsPanel").hide().removeClass("hidden")
$("#chatPanel").hide().removeClass("hidden")
//$("#chatPanel").removeClass("hidden")
//var html01 = '<button type="button" class="btn btn-default" data-toggle="modal" data-target="#inputModal" data-dialog="inviteTopic">+</button>'
//$(html01).appendTo("#topicSubscribers")
// Logging
function logger(msg) {
var log = $("#log")
var d = log[0]
log.prepend("<b>[" + _getTimeStr() + "]</b> " + msg + "\n")
d.scrollTop = 0
}
// Do processing after loggin in
function after_login() {
$("#loginPanel").hide()
$("#contactsPanel").show()
// TODO(gene): mode=null (default), init=null (N/A), browse=...
var me = Tinode.topicMe({
"onData": function(data) {
// TODO(gene): handle invites
},
"onSubsChange": function(sub) {
append_topic(sub)
},
"onInfoChange": function(info) {
var name = info.public || "<i>Anonymous</i>"
$("#current-user-name").text(name)
$("#current-user-status").text("not implemented")
}})
me.Subscribe({"get": "info sub data"})
}
function append_topic(cont) {
var id = "cont" + cont.topic
var flag = "flag" + cont.topic
var group = (cont.topic.indexOf("grp") === 0)
if (!($("#" + id).length)) {
var icon = group ? 'glyphicon-bullhorn' : 'glyphicon-user'
var name = cont.public || "<i>Anonymous</i>"
var status = cont.private || "not set"
var html = '\
<li class="contact" id="' + id + '" data-topicname="' + cont.topic + '" data-flagname="' + flag + '">\
<span class="pull-left">\
<span class="glyphicon ' + icon + '"></span>\
</span>\
<div class="contact-body">\
<h5 class="list-group-item-heading">' + name + ' \
<span class="pull-right hidden" id="' + flag + '"><span class="glyphicon glyphicon-flag"></span></span></h5>\
<p class="list-group-item-text">' + status + '</p>\
</div>\
</li>'
$(html).appendTo(".contacts")
$("#" + flag).hide().removeClass("hidden")
var lastMsg = cont.lastMsg ? new Date(cont.lastMsg) : new Date(2014, 10, 25, 5, 6, 2) // 1414213562 in Unix time, UTC
var lastSeen = cont.seenTag ? new Date(cont.seenTag) : new Date(2014, 10, 25, 5, 6, 2)
if (lastMsg > lastSeen) {
$("#" + flag).show()
}
}
}
function insert_chat_message(msg, when, isMe, name) {
var html
var time = _timeSince(when)
if (isMe) {
html ='<li class="left clearfix">\
<span class="chat-user pull-left">\
<big><span class="glyphicon glyphicon-user"></span></big>\
</span>\
<div class="chat-body clearfix">\
<div class="header">\
<strong class="primary-font">me</strong> <small class="pull-right text-muted">\
<span class="glyphicon glyphicon-time"></span> '+ time +' ago</small>\
</div>\
<p>' + msg.content + '</p>\
</div>\
</li>'
} else {
html = '<li class="right clearfix">\
<span class="chat-user pull-right">\
<big><span class="glyphicon glyphicon-user"></span></big>\
</span>\
<div class="chat-body clearfix">\
<div class="header">\
<small class=" text-muted"><span class="glyphicon glyphicon-time"></span> ' + time + ' ago</small>\
<strong class="pull-right primary-font">' + name + '</strong>\
</div>\
<p>' + msg.content + '</p>\
</div>\
</li>'
}
$(html).appendTo("#chatFlow")
scrollToLastMessage()
}
function insert_topic_subscriber(sub) {
var cap = sub.public ? sub.public.substring(0,1) : "?"
var html = '<button type="button" class="btn btn-default" id="sub'+ sub.user +'">'+cap+'</button>'
$(html).appendTo("#topicSubscribers")
$("#sub" + sub.user).on("click", function(e) {
try {
var topic = $("#chatCurrentTopic").val()
Tinode.streaming.Get(sub.user)
} catch (ex) {
logger(ex)
}
return false
});
}
function show_conversation_panel(topicname) {
var topic = Tinode.topic(topicname, {
"onData": function(data) {
var user = topic.UserInfo(data.from)
var name = (user.public || "anonymous")
insert_chat_message(data, data.ts, (data.from === Tinode.getCurrentUserID()), name)
},
"onInfoChange": function(info) {
$("#chatUserName").text(info.public || "Anonymous (" + info.name + ")")
if (info.name) {
$("#chatCurrentTopic").val(info.name)
console.log("Set current topic.name to '" + $("#chatCurrentTopic").val() + "'")
}
},
"onSubsChange": function(sub) {
insert_topic_subscriber(sub)
}
})
topic.Subscribe({
"get": (topicname === "new" ? "info sub" : "info sub data"),
"browse": {
"ascnd": false,
"since": null,
"before": null,
"limit": 8
},
"init": {
"defacs": {"auth": "RWP", "anon": "X"}, // also OK to skip, server will use appropriate defaults
"public": "Newly created topic",
"private": new Date()
}
}).catch(function() {
// do nothing
})
$("#chatUserName").text(topic.public || "Anonymous")
$("#chatCurrentTopic").val(topicname)
// Reset the panel before loading subscribers and messages
$("#topicSubscribers").empty()
var html = '<button type="button" class="btn btn-default" data-toggle="modal" data-target="#inputModal" \
data-dialog="inviteTopic">+</button>'
$(html).appendTo("#topicSubscribers")
$("#chatFlow").empty()
$("#chatPanel").show()
}
function init() {
// TODO(gene): remove the baseUrl parameter
Tinode.init(APIKEY, $("#baseUrl").val()) // baseUrl is available during debugging only
var transport = $("input:radio[name=transport]:checked" ).val();
Tinode.streaming.init(transport)
//Tinode.streaming.setEcho($("#requestEcho").is(":checked"))
Tinode.streaming.wantAkn(true)
Tinode.logToConsole(true)
Tinode.streaming.onRawMessage = logger
//Tinode.streaming.onDataMessage = function(data) {}
Tinode.streaming.onConnect = function(code, text, params) {
if (code >= 200 && code < 300) {
logger("connected " + text + "; " + JSON.stringify(params))
var login = $("#username").val()
var pass = $("#password").val()
Tinode.streaming.LoginBasic(login, pass)
.then(after_login)
.catch(function(err) {
logger("login failed: " + err)
$("#loginButton").prop("disabled", false);
})
} else {
logger("connect failed: (" + code + ") " + text)
}
}
Tinode.streaming.onDisconnect = function() {
$("#loginButton").prop("disabled", false);
$("#loginSettingsPanel").hide()
$("#contactsPanel").hide()
$("#loginPanel").show()
logger("disconnected")
}
Tinode.streaming.onPresenceChange = function(who, cont) {
var contact = $("#cont" + who)
if (contact) {
var user = contact.find(".glyphicon-user")
if (cont.online) {
user.addClass("online")
} else {
user.removeClass("online")
}
contact.find("p").text(cont.status)
}
}
}
// Default initialization
init()
$(".dropdown-menu").on("click", function(e) {
if($(this).hasClass("dropdown-menu-form")) {
e.stopPropagation()
}
})
// Login pannel
// User asks to show settings panel
$("#loginConfigButton").on("click", function(e) {
$("#loginPanel").hide()
$("#loginSettingsPanel").show()
})
// Create new user account
$("#registerUserButton").on("click", function(e) {
$("#loginPanel").hide()
$("#registerUserPanel").show()
})
// Hide login pannel
$("#loginConfigCancelButton").on("click", function(e) {
$("#loginSettingsPanel").hide()
$("#loginPanel").show()
})
// Connect and login
$("#loginButton").on("click", function(e) {
try {
$("#loginButton").prop("disabled", true);
Tinode.streaming.Connect(false)
} catch (ex) {
logger(ex)
}
return false
})
// Login settings pannel
// Reinitialize Tinode and Tinode.streaming
$("#loginSettings").on("submit", function(e) {
// Re-initialize Tinode with new parameters
init()
$("#loginSettingsPanel").hide()
$("#loginPanel").show()
return false
})
// Reinitialize Tinode and Tinode.streaming
$("#registerUser").on("submit", function(e) {
$("#registerUser").hide()
$("#loginPanel").show()
return false
})
// Just switch back to Login panel
$("#loginSettingsCancel").on("click", function(e) {
$("#loginSettingsPanel").hide()
$("#loginPanel").show()
})
// Contacts panel
// Click on a contact -- start chat
$(document).on("click", ".contact", function(e) {
// get topic name
var topicName = e.currentTarget.dataset.topicname
// Clear the unread messages flag
$("#" + e.currentTarget.dataset.flagname).hide()
// Initialize and display the panel
show_conversation_panel(topicName)
})
// Add a new group chat
$("#groupChatButton").on("click", function(e) {
var topicName = ($("#topicName").val() || "new")
logger("Start chat on a topic: " + topicName)
show_conversation_panel(topicName)
})
// Chat panel
function send_message() {
var msg = $("#chatInput").val()
var topic = $("#chatCurrentTopic").val()
if (msg && topic) {
logger("Send to " + topic + ": " + msg)
Tinode.streaming.Publish(topic, msg)
}
$("#chatInput").val("")
}
// Send chat message
$("#chatSendButton").on("click", function(e) {
send_message()
})
// Reaction to Enter
var current_focus_id
$(":input").focus(function () {
current_focus_id = this.id;
})
$(document).keypress(function(e) {
if(e.keyCode == 13) {
if (current_focus_id === "chatInput") {
send_message()
}
}
})
function scrollToLastMessage() {
var el = $("#chatFlow")[0]
$("#chatFlow").scrollTop(Math.max(0, el.scrollHeight - el.clientHeight))
}
// Modal dialog with subscription changes
$("#sendSubUpdate").on("click", function(e){
//Tinode.streaming.Set()
$("#inputModal").modal('hide');
})
function subscriber_update() {
$("#subUser").val()
$("#subMode").val()
$("#subInfo").val()
Tinode.streaming.Set()
}
})
</script>
</head>
<body>
<div class="container max-width max-height">
<div class="row">
<div class="col-md-12">
<h3>Tinode chat demo</h3>
</div>
</div>
<div class="row">
<div class="col-md-2">
<div class="panel panel-primary" id="loginPanel">
<div class="panel-heading">
<h5 class="panel-title">Connect and login
<div class="pull-right">
<button class="btn btn-default btn-xs" type="button" id="registerUserButton">
&nbsp;<span class="glyphicon glyphicon-user"></span>&nbsp;
</button>
<button class="btn btn-default btn-xs" type="button" id="loginConfigButton">
&nbsp;<span class="glyphicon glyphicon-cog"></span>&nbsp;
</button>
</div>
</h5>
</div>
<div class="panel-body">
<h5 id="loginError" class="hidden"><span class="label label-danger">Error!</span> <span id="errorBody"></span></h5>
<form role="form" id="login">
<div class="form-group">
<input type="text" class="form-control" id="username" placeholder="User name (alice, bob, ... frank)">
</div>
<div class="form-group">
<input type="password" class="form-control" id="password" placeholder="Password">
</div>
<button type="submit" class="btn btn-primary" id="loginButton">Login</button>
</form>
</div>
</div>
<!-- configuration pannel -->
<div class="panel panel-primary hidden" id="loginSettingsPanel">
<div class="panel-heading">
<h5 class="panel-title">Settings
<div class="pull-right">
<button class="btn btn-default btn-xs" type="button" id="loginConfigCancelButton">
&nbsp;<span class="glyphicon glyphicon-cog"></span>&nbsp;
</button>
</div>
</h5>
</div>
<div class="panel-body">
<form role="form" id="loginSettings">
<div class="form-group">
<label for="baseUrl">Base URL</label>
<input type="url" class="form-control" id="baseUrl" value="http://localhost:8088/">
</div>
<div class="form-group">
<label>Streaming transport:</label>
<div class="radio">
<label>
<input type="radio" name="transport" id="transport_default" value="default" checked>
Default
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="transport" id="transport_websocket" value="ws">
Force websocket
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="transport" id="transport_longpoll" value="lp">
Force long polling
</label>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="requestAkn" checked disabled> Aknowledgements
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="requestEcho" disabled> Echo packets
</label>
</div>
<button type="submit" class="btn btn-primary">OK</button>
<button type="button" class="btn btn-default" id="loginSettingsCancel">Cancel</button>
</form>
</div>
</div>
<!-- user registration panel -->
<div class="panel panel-primary hidden" id="registerUserPanel">
<div class="panel-heading">
<h5 class="panel-title">Register new user
<div class="pull-right">
<button class="btn btn-default btn-xs" type="button" id="registerUserCancelButton">
&nbsp;<span class="glyphicon glyphicon-cog"></span>&nbsp;
</button>
</div>
</h5>
</div>
<div class="panel-body">
<form role="form" id="register">
<div class="form-group">
<label for="newLogin">Login</label>
<input type="text" class="form-control" id="newLogin" placeholder="george">
<label for="newPassword">Password</label>
<input type="text" class="form-control" id="newPassword" placeholder="george123">
</div>
<div class="form-group">
<label for="newPublic">Public description</label>
<input type="text" class="form-control" id="newLogin" placeholder="George Shrub">
<label for="newPrivate">Private description</label>
<input type="text" class="form-control" id="newPassword" placeholder="{&quot;name&quot;: &quot;value&quot;}">
</div>
<button type="submit" class="btn btn-primary">OK</button>
<button type="button" class="btn btn-default" id="registerUserCancel">Cancel</button>
</form>
</div>
</div>
<!-- pannel shown in place of login when user logs in -->
<div class="panel panel-primary hidden" id="contactsPanel">
<div class="panel-heading">
<span class="pull-left" href="#">
<span class="glyphicon glyphicon-user"></span>
</span>
<span style="margin-left:1.0em;"><big id="current-user-name">User Name</big></span>
<div class="pull-right">
<button class="btn btn-default btn-xs" type="button" id="contactsConfigButton">
&nbsp;<span class="glyphicon glyphicon-cog"></span>&nbsp;
</button>
</div>
<p class="list-group-item-text" style="margin-left:1.75em;" id="current-user-status">user status</p>
</div>
<div class="panel-body">
<ul class="contacts" id="contactsList">
<li>
<div class="input-group">
<input type="text" class="form-control" id="topicName" placeholder="Start new chat">
<span class="input-group-btn">
<button class="btn btn-primary" type="button" id="groupChatButton">
<span class="glyphicon glyphicon-plus-sign"></span>
</button>
</span>
</div>
</li>
<!-- contacts are inserted here -->
</ul>
</div>
</div>
</div><!-- /col-md-2 -->
<div class="col-md-2">
<!-- conversation panel -->
<div class="panel panel-primary hidden" id="chatPanel">
<div class="panel-heading">
<span class="glyphicon glyphicon-user"></span> &nbsp;<span id="chatUserName">User Name</span>
</div>
<!-- topic subscribers -->
<div class="btn-group" role="group" aria-label="ABC" id="topicSubscribers">
<!-- buttons for subscribers inserted here -->
</div>
<div class="panel-body">
<ul class="chat" id="chatFlow">
<!-- chat messages are inserted here -->
</ul>
</div>
<div class="panel-footer">
<div class="input-group">
<input id="chatCurrentTopic" type="hidden" />
<input id="chatInput" type="text" class="form-control input-sm" placeholder="Type your message here..." />
<span class="input-group-btn">
<button class="btn btn-primary btn-sm" id="chatSendButton">
<span class="glyphicon glyphicon-send"></span>
</button>
</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<h4>Activity log</h4>
<pre class="scrollable log" id="log">
not connected</pre>
</div><!-- col-md- -->
</div><!-- row -->
<div class="row">
<div class="col-md-12">UI created with <a href="http://getbootstrap.com/">Bootstrap</a> &amp; <a href="http://jquery.com/">jQuery</a>. Tinode has no external dependencies.</div>
</div>
</div><!-- container -->
<!-- modal dialog which asks for user ID to invite to a topic -->
<div class="modal fade" id="inputModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-sm" role="document">
<div class="modal-content">
<div class="modal-body">
<form>
<div class="form-group">
<input type="text" class="form-control" id="subUser" placeholder="User ID to update or invite">
<input type="text" class="form-control" id="subMode" placeholder="Access mode, RWS...">
<textarea class="form-control" id="subInfo" placeholder="free-form info"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="sendSubUpdate">Do!</button>
</div>
</div>
</div>
</div>
</body>
<script type="text/javascript">
// Basic time formatter
function _getTimeStr() {
var now = new Date()
var hh = now.getHours();
var mm = now.getMinutes();
var ss = now.getSeconds();
if (hh < 10) { hh = "0" + hh }
if (mm < 10) { mm = "0" + mm }
if (ss < 10) { ss = "0" + ss }
return hh + ":" + mm + ":" + ss
}
function _timeSince(date) {
var seconds = Math.floor((new Date() - date) / 1000);
var interval = Math.floor(seconds / 31536000);
if (interval > 1) {
return interval + " years";
}
interval = Math.floor(seconds / 2592000);
if (interval > 1) {
return interval + " months";
}
interval = Math.floor(seconds / 86400);
if (interval > 1) {
return interval + " days";
}
interval = Math.floor(seconds / 3600);
if (interval > 1) {
return interval + " hours";
}
interval = Math.floor(seconds / 60);
if (interval > 1) {
return interval + " minutes";
}
return Math.floor(seconds) + " seconds";
}
</script>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<script src="bower_components/webcomponentsjs/webcomponents.min.js"></script>
<link rel="import" href="bower_components/core-scaffold/core-scaffold.html">
<link rel="import" href="bower_components/core-item/core-item.html">
<link rel="import" href="bower_components/paper-input/paper-input.html">
<link rel="import" href="bower_components/paper-fab/paper-fab.html">
</head>
<body fullbleed unresolved>
<core-scaffold>
<!-- Drawer panel -->
<core-header-panel navigation flex>
<core-toolbar class="tall">
<!-- an avatar and username will be here -->
</core-toolbar>
</core-header-panel>
<!-- App Title -->
<div tool layout horizontal flex>
<span flex>Kitteh Anonymous</span>
<core-icon icon="account-circle"></core-icon>
<span><!-- number of people online --></span>
</div>
<!-- Main content -->
<div flex>...</div>
</core-scaffold>
</body>
</html>

View File

@ -0,0 +1,59 @@
// Package adapter contains the interfaces to be implemented by the database adapter
package adapter
import (
t "github.com/tinode/chat/server/store/types"
"time"
)
// Adapter is the interface that must be implemented by a database
// adapter. The current schema supports a single connection by database type.
type Adapter interface {
Open(dsn string) error
Close() error
IsOpen() bool
ResetDb() error
// User management
GetPasswordHash(appid uint32, username string) (t.Uid, []byte, error)
UserCreate(appid uint32, usr *t.User) (err error, dupeUserName bool)
UserGet(appId uint32, id t.Uid) (*t.User, error)
UserGetAll(appId uint32, ids []t.Uid) ([]t.User, error)
//GetLastSeenAndStatus(appid uint32, id t.Uid) (time.Time, interface{}, error)
UserFind(appId uint32, params map[string]interface{}) ([]t.User, error)
UserDelete(appId uint32, id t.Uid, soft bool) error
UserUpdateStatus(appid uint32, uid t.Uid, status interface{}) error
ChangePassword(appid uint32, id t.Uid, password string) error
UserUpdate(appid uint32, uid t.Uid, update map[string]interface{}) error
// Topic/contact management
// TopicCreate creates a topic
TopicCreate(appid uint32, topic *t.Topic) error
// TopicCreateP2P creates a p2p topic
TopicCreateP2P(appId uint32, initiator, invited *t.Subscription) error
// TopicGet loads a single topic by name, if it exists. If the topic does not exist the call returns (nil, nil)
TopicGet(appid uint32, topic string) (*t.Topic, error)
TopicsForUser(appid uint32, uid t.Uid, opts *t.BrowseOpt) ([]t.Subscription, error)
UsersForTopic(appid uint32, topic string, opts *t.BrowseOpt) ([]t.Subscription, error)
//UsersForP2P(appid uint32, uid1, uid2 t.Uid) ([]t.Subscription, error)
TopicShare(appid uint32, acl []t.Subscription) (int, error)
UpdateLastSeen(appid uint32, topic string, uid t.Uid, tag string, when time.Time) error
TopicDelete(appid uint32, userDbId, topic string) error
TopicUpdateLastMsgTime(appid uint32, topic string, ts time.Time) error
TopicUpdate(appid uint32, topic string, update map[string]interface{}) error
// SubscriptionGet reads a subscription of a user to a topic
SubscriptionGet(appid uint32, topic string, user t.Uid) (*t.Subscription, error)
// SubsForUser gets a list of topics of interest for a given user
SubsForUser(appId uint32, user t.Uid, opts *t.BrowseOpt) ([]t.Subscription, error)
// SubsForTopic gets a list of subscriptions to a given topic
SubsForTopic(appId uint32, topic string, opts *t.BrowseOpt) ([]t.Subscription, error)
// SubsUpdate updates pasrt of a subscription object. Pass nil for fields which don't need to be updated
SubsUpdate(appid uint32, topic string, user t.Uid, update map[string]interface{}) error
// Messages
MessageSave(appId uint32, msg *t.Message) error
MessageGetAll(appId uint32, topic string, opts *t.BrowseOpt) ([]t.Message, error)
MessageDelete(appId uint32, id t.Uid) error
}

365
server/store/store.go Normal file
View File

@ -0,0 +1,365 @@
/*****************************************************************************
* Storage schema
*****************************************************************************
* System-accessible tables:
***************************
* 1. Customer (customer of the service)
*****************************
* Customer-accessible tables:
*****************************
* 2. Application (a customer may have multiple applications)
* 3. Application keys (an application may have multiple API keys)
****************************************
* Application/end-user-accessible tables
****************************************
* 4. User (end-user)
* 5. Session (data associated with logged-in user)
* 6. Topics (aka Inbox; a list of user's threads/conversations, with access rights, indexed by user id and by
topic name, neither userId nor topicName are unique)
* 7. Messages (persistent store of messages)
* 8. Contacts (a.k.a. ledger, address book)
*****************************************************************************/
package store
import (
"errors"
"github.com/tinode/chat/server/store/adapter"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
"strings"
"time"
)
const (
MAX_USERS_FOR_TOPIC = 32
)
var adaptr adapter.Adapter
// Open initializes the persistence system. Adapter holds a connection pool for a single database.
func Open(dataSourceName string) error {
if adaptr == nil {
return errors.New("store: attept to Open an adapter before registering")
}
if adaptr.IsOpen() {
return errors.New("store: connection already opened")
}
return adaptr.Open(dataSourceName)
}
func Close() error {
if adaptr.IsOpen() {
return adaptr.Close()
} else {
return errors.New("store: connection already closed")
}
}
func IsOpen() bool {
if adaptr != nil {
return adaptr.IsOpen()
} else {
return false
}
}
func ResetDb() error {
return adaptr.ResetDb()
}
// Register makes a persistence adapter available by the provided name.
// If Register is called twice with the same name or if the adapter is nil,
// it panics.
func Register(adapter adapter.Adapter) {
if adapter == nil {
panic("store: Register adapter is nil")
}
if adaptr != nil {
panic("store: Adapter already registered")
}
adaptr = adapter
}
// Users struct to hold methods for persistence mapping for the User object.
type UsersObjMapper struct{}
// Users is the ancor for storing/retrieving User objects
var Users UsersObjMapper
// CreateUser inserts User object into a database, updates creation time and assigns UID
func (u UsersObjMapper) Create(appid uint32, user *types.User, scheme, secret string, private interface{}) (*types.User, error) {
if scheme == "basic" {
if splitAt := strings.Index(secret, ":"); splitAt > 0 {
user.InitTimes()
user.Username = secret[:splitAt]
var err error
user.Passhash, err = bcrypt.GenerateFromPassword([]byte(secret[splitAt+1:]), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// TODO(gene): maybe have some additional handling of duplicate user name error
err, _ = adaptr.UserCreate(appid, user)
user.Passhash = nil
if err != nil {
return nil, err
}
// Create user's subscription to !me. The !me topic is ephemeral, the topic object need not to be inserted.
err = Subs.Create(appid,
&types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: user.CreatedAt},
User: user.Id,
Topic: user.Uid().UserId(),
ModeWant: types.ModeSelf,
ModeGiven: types.ModeSelf,
Private: private,
})
if err != nil {
return nil, err
}
return user, nil
} else {
return nil, errors.New("store: invalid format of secret")
}
}
return nil, errors.New("store: unknown authentication scheme '" + scheme + "'")
}
// Process user login. TODO(gene): abstract out the authentication scheme
func (UsersObjMapper) Login(appid uint32, scheme, secret string) (types.Uid, error) {
if scheme == "basic" {
if splitAt := strings.Index(secret, ":"); splitAt > 0 {
uname := secret[:splitAt]
password := secret[splitAt+1:]
uid, hash, err := adaptr.GetPasswordHash(appid, uname)
if err != nil {
return types.ZeroUid, err
} else if uid.IsZero() {
// Invalid login
return types.ZeroUid, nil
}
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err != nil {
// Invalid password
return types.ZeroUid, nil
}
//log.Println("Logged in as", uid, uid.String())
return uid, nil
} else {
return types.ZeroUid, errors.New("store: invalid format of secret")
}
}
return types.ZeroUid, errors.New("store: unknown authentication scheme '" + scheme + "'")
}
// TODO(gene): implement
func (UsersObjMapper) Get(appid uint32, uid types.Uid) (*types.User, error) {
return adaptr.UserGet(appid, uid)
}
/*
func (u UsersObjMapper) GetLastSeenAndStatus(appid uint32, id types.Uid) (time.Time, interface{}, error) {
return adaptr.GetLastSeenAndStatus(appid, id)
}
*/
// TODO(gene): implement
func (UsersObjMapper) Find(appId uint32, params map[string]interface{}) ([]types.User, error) {
return nil, errors.New("store: not implemented")
}
// TODO(gene): implement
func (UsersObjMapper) Delete(appId uint32, id types.Uid, soft bool) error {
return errors.New("store: not implemented")
}
func (UsersObjMapper) UpdateStatus(appid uint32, id types.Uid, status interface{}) error {
return errors.New("store: not implemented")
}
// ChangePassword changes user's password in "basic" authentication scheme
func (UsersObjMapper) ChangeAuthCredential(appid uint32, uid types.Uid, scheme, secret string) error {
if scheme == "basic" {
if splitAt := strings.Index(secret, ":"); splitAt > 0 {
return adaptr.ChangePassword(appid, uid, secret[splitAt+1:])
}
return errors.New("store: invalid format of secret")
}
return errors.New("store: unknown authentication scheme '" + scheme + "'")
}
func (UsersObjMapper) Update(appid uint32, uid types.Uid, update map[string]interface{}) error {
update["UpdatedAt"] = types.TimeNow()
return adaptr.UserUpdate(appid, uid, update)
}
// GetSubs loads a list of subscriptions for the given user
func (u UsersObjMapper) GetSubs(appid uint32, id types.Uid, opts *types.BrowseOpt) ([]types.Subscription, error) {
return adaptr.SubsForUser(appid, id, opts)
}
// GetTopics is exacly the same as Topics.GetForUser
func (u UsersObjMapper) GetTopics(appid uint32, id types.Uid, opts *types.BrowseOpt) ([]types.Subscription, error) {
return adaptr.TopicsForUser(appid, id, opts)
}
// Topics struct to hold methods for persistence mapping for the topic object.
type TopicsObjMapper struct{}
var Topics TopicsObjMapper
// Create creates a topic and owner's subscription to topic
func (TopicsObjMapper) Create(appid uint32, topic *types.Topic, owner types.Uid, private interface{}) error {
topic.InitTimes()
err := adaptr.TopicCreate(appid, topic)
if err != nil {
return err
}
if !owner.IsZero() {
err = Subs.Create(appid,
&types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: topic.CreatedAt},
User: owner.String(),
Topic: topic.Name,
ModeGiven: types.ModeFull,
ModeWant: topic.GetAccess(owner),
Private: private})
}
return err
}
// CreateP2P creates a P2P topic by generating two user's subsciptions to each other.
func (TopicsObjMapper) CreateP2P(appid uint32, initiator, invited *types.Subscription) error {
if users, err := adaptr.UserGetAll(appid, []types.Uid{
types.ParseUid(initiator.User),
types.ParseUid(invited.User)}); err != nil {
return err
} else if len(users) == 2 {
var other = 1
if users[0].Id == invited.User {
other = 0
}
initiator.SetPublic(users[(other+1)%2].Public)
invited.SetPublic(users[other].Public)
invited.ModeWant = users[other].Access.Auth
} else {
// invited user does not exist
return errors.New("invited user does not exist " + initiator.Topic)
}
// initiator is given as much access as permitted by the other user
initiator.ModeGiven = invited.ModeWant
initiator.InitTimes()
invited.InitTimes()
return adaptr.TopicCreateP2P(appid, initiator, invited)
}
// Get a single topic with a list of relevent users de-normalized into it
func (TopicsObjMapper) Get(appid uint32, topic string) (*types.Topic, error) {
return adaptr.TopicGet(appid, topic)
}
// GetUsers loads subscriptions for topic plus loads user.Public
func (TopicsObjMapper) GetUsers(appid uint32, topic string, opts *types.BrowseOpt) ([]types.Subscription, error) {
// Limit the number of subscriptions per topic
if opts == nil {
opts = &types.BrowseOpt{Limit: MAX_USERS_FOR_TOPIC}
}
return adaptr.UsersForTopic(appid, topic, opts)
}
// GetSubs loads a list of subscriptions to the given topic, user.Public is not loaded
func (TopicsObjMapper) GetSubs(appid uint32, topic string, opts *types.BrowseOpt) ([]types.Subscription, error) {
// Limit the number of subscriptions per topic
if opts == nil {
opts = &types.BrowseOpt{Limit: MAX_USERS_FOR_TOPIC}
}
return adaptr.SubsForTopic(appid, topic, opts)
}
func (TopicsObjMapper) UpdateLastSeen(appid uint32, topic string, id types.Uid, tag string, when time.Time) error {
return adaptr.UpdateLastSeen(appid, topic, id, tag, when)
}
func (TopicsObjMapper) Update(appid uint32, topic string, update map[string]interface{}) error {
update["UpdatedAt"] = types.TimeNow()
return adaptr.TopicUpdate(appid, topic, update)
}
// Topics struct to hold methods for persistence mapping for the topic object.
type SubsObjMapper struct{}
var Subs SubsObjMapper
func (SubsObjMapper) Create(appid uint32, sub *types.Subscription) error {
sub.InitTimes()
_, err := adaptr.TopicShare(appid, []types.Subscription{*sub})
return err
}
func (SubsObjMapper) Get(appid uint32, topic string, user types.Uid) (*types.Subscription, error) {
return adaptr.SubscriptionGet(appid, topic, user)
}
// Update changes values of user's subscription.
func (SubsObjMapper) Update(appid uint32, topic string, user types.Uid, update map[string]interface{}) error {
update["UpdatedAt"] = types.TimeNow()
return adaptr.SubsUpdate(appid, topic, user, update)
}
// Messages struct to hold methods for persistence mapping for the Message object.
type MessagesObjMapper struct{}
var Messages MessagesObjMapper
// Save message
func (MessagesObjMapper) Save(appid uint32, msg *types.Message) error {
msg.InitTimes()
// Need a transaction here, RethinkDB does not support transactions
if err := adaptr.TopicUpdateLastMsgTime(appid, msg.Topic, msg.CreatedAt); err != nil {
return err
}
return adaptr.MessageSave(appid, msg)
}
// Soft-delete semmsages for the current user
func (MessagesObjMapper) DeleteAll(appId uint32, user types.Uid, topic string) error {
return errors.New("store: not implemented")
}
func (MessagesObjMapper) GetAll(appid uint32, topic string, opt *types.BrowseOpt) ([]types.Message, error) {
return adaptr.MessageGetAll(appid, topic, opt)
}
func (MessagesObjMapper) Delete(appId uint32, uid types.Uid) error {
return errors.New("store: not implemented")
}
func ZeroUid() types.Uid {
return types.ZeroUid
}
func UidFromBytes(b []byte) types.Uid {
var uid types.Uid
(&uid).UnmarshalBinary(b)
return uid
}

542
server/store/types/types.go Normal file
View File

@ -0,0 +1,542 @@
package types
import (
"encoding/base64"
"encoding/binary"
"errors"
"strings"
"time"
)
// Uid is a database-specific record id, suitable to be used as a primary key.
type Uid uint64
var ZeroUid Uid = 0
const (
uid_BASE64_UNPADDED = 11
uid_BASE64_PADDED = 12
p2p_BASE64_UNPADDED = 22
p2p_BASE64_PADDED = 24
)
func (uid Uid) IsZero() bool {
return uid == 0
}
func (uid Uid) Compare(u2 Uid) int {
if uid < u2 {
return -1
} else if uid > u2 {
return 1
}
return 0
}
func (uid *Uid) MarshalBinary() ([]byte, error) {
dst := make([]byte, 8)
binary.LittleEndian.PutUint64(dst, uint64(*uid))
return dst, nil
}
func (uid *Uid) UnmarshalBinary(b []byte) error {
if len(b) < 8 {
return errors.New("Uid.UnmarshalBinary: invalid length")
}
*uid = Uid(binary.LittleEndian.Uint64(b))
return nil
}
func (uid *Uid) UnmarshalText(src []byte) error {
if len(src) != uid_BASE64_UNPADDED {
return errors.New("Uid.UnmarshalText: invalid length")
}
dec := make([]byte, base64.URLEncoding.DecodedLen(uid_BASE64_PADDED))
for len(src) < uid_BASE64_PADDED {
src = append(src, '=')
}
count, err := base64.URLEncoding.Decode(dec, src)
if count < 8 {
if err != nil {
return errors.New("Uid.UnmarshalText: failed to decode " + err.Error())
}
return errors.New("Uid.UnmarshalText: failed to decode")
}
*uid = Uid(binary.LittleEndian.Uint64(dec))
return nil
}
func (uid *Uid) MarshalText() ([]byte, error) {
if *uid == 0 {
return []byte{}, nil
}
src := make([]byte, 8)
dst := make([]byte, base64.URLEncoding.EncodedLen(8))
binary.LittleEndian.PutUint64(src, uint64(*uid))
base64.URLEncoding.Encode(dst, src)
return dst[0:uid_BASE64_UNPADDED], nil
}
func (uid *Uid) MarshalJSON() ([]byte, error) {
dst, _ := uid.MarshalText()
return append(append([]byte{'"'}, dst...), '"'), nil
}
func (uid *Uid) UnmarshalJSON(b []byte) error {
size := len(b)
if size != (uid_BASE64_UNPADDED + 2) {
return errors.New("Uid.UnmarshalJSON: invalid length")
} else if b[0] != '"' || b[size-1] != '"' {
return errors.New("Uid.UnmarshalJSON: unrecognized")
}
return uid.UnmarshalText(b[1 : size-1])
}
func (uid Uid) String() string {
buf, _ := uid.MarshalText()
return string(buf)
}
func ParseUid(s string) Uid {
var uid Uid
uid.UnmarshalText([]byte(s))
return uid
}
func (uid Uid) UserId() string {
return uid.PrefixId("usr")
}
func (uid Uid) PrefixId(prefix string) string {
if uid.IsZero() {
return ""
}
return prefix + uid.String()
}
func ParseUserId(s string) Uid {
var uid Uid
if strings.HasPrefix(s, "usr") {
(&uid).UnmarshalText([]byte(s)[3:])
}
return uid
}
//func (uid Uid) P2PTopic(u2 Uid) string {
func (uid Uid) P2PName(u2 Uid) string {
var b1, b2 []byte
b1, _ = uid.MarshalBinary()
if !u2.IsZero() {
b2, _ = u2.MarshalBinary()
if uid < u2 {
b1 = append(b1, b2...)
} else if uid > u2 {
b1 = append(b2, b1...)
} else {
return ""
}
return "p2p" + base64.URLEncoding.EncodeToString(b1)[:p2p_BASE64_UNPADDED]
}
return ""
}
// ParseP2P extracts uids from the name of a p2p topic
func ParseP2P(p2p string) (uid1, uid2 Uid, err error) {
if strings.HasPrefix(p2p, "p2p") {
src := []byte(p2p)[3:]
if len(src) != p2p_BASE64_UNPADDED {
err = errors.New("ParseP2P: invalid length")
return
}
dec := make([]byte, base64.URLEncoding.DecodedLen(p2p_BASE64_PADDED))
for len(src) < p2p_BASE64_PADDED {
src = append(src, '=')
}
var count int
count, err = base64.URLEncoding.Decode(dec, src)
if count < 16 {
if err != nil {
err = errors.New("ParseP2P: failed to decode " + err.Error())
}
err = errors.New("ParseP2P: invalid decoded length")
return
}
uid1 = Uid(binary.LittleEndian.Uint64(dec))
uid2 = Uid(binary.LittleEndian.Uint64(dec[8:]))
} else {
err = errors.New("ParseP2P: missing or invalid prefix")
}
return
}
// Header shared by all stored objects
type ObjHeader struct {
Id string // using string to get around rethinkdb's problems with unit64
id Uid
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
func (h *ObjHeader) Uid() Uid {
if h.id.IsZero() && h.Id != "" {
h.id.UnmarshalText([]byte(h.Id))
}
return h.id
}
func (h *ObjHeader) SetUid(uid Uid) {
h.id = uid
h.Id = uid.String()
}
func TimeNow() time.Time {
return time.Now().UTC().Round(time.Millisecond)
}
// InitTimes initializes time.Time variables in the header to current time
func (h *ObjHeader) InitTimes() {
if h.CreatedAt.IsZero() {
h.CreatedAt = TimeNow()
}
h.UpdatedAt = h.CreatedAt
h.DeletedAt = nil
}
// InitTimes initializes time.Time variables in the header to current time
func (h *ObjHeader) MergeTimes(h2 *ObjHeader) {
// Set the creation time to the earliest value
if h.CreatedAt.IsZero() || (!h2.CreatedAt.IsZero() && h2.CreatedAt.Before(h.CreatedAt)) {
h.CreatedAt = h2.CreatedAt
}
// Set the update time to the latest value
if h.UpdatedAt.Before(h2.UpdatedAt) {
h.UpdatedAt = h2.UpdatedAt
}
// Set deleted time to the latest value
if h2.DeletedAt != nil && (h.DeletedAt == nil || h.DeletedAt.Before(*h2.DeletedAt)) {
h.DeletedAt = h2.DeletedAt
}
}
// Stored user
type User struct {
ObjHeader
State int // Unconfirmed, Active, etc.
Username string
Passhash []byte
Access DefaultAccess // Default access to user
Public interface{}
}
const max_devices = 8
type AccessMode uint
// User access to topic
const (
ModeSub AccessMode = 1 << iota // user can Read, i.e. {sub} (R)
ModePub // user can Write, i.e. {pub} (W)
ModePres // user can receive presence updates (P)
ModeShare // user can invite other people to join (S)
ModeDelete // user can hard-delete messages (D), only owner can completely delete
ModeOwner // user is the owner (O) - full access
ModeBanned // user has no access, requests to share/gain access/{sub} are ignored (X)
ModeNone AccessMode = 0 // No access, requests to gain access are processed normally (N)
// Read & write
ModePubSub AccessMode = ModeSub | ModePub
// normal user's access to a topic
ModePublic AccessMode = ModeSub | ModePub | ModePres
// self-subscription to !me - user can only read and delete incoming invites
ModeSelf AccessMode = ModeSub | ModeDelete | ModePres
// owner's subscription to a generic topic
ModeFull AccessMode = ModeSub | ModePub | ModePres | ModeShare | ModeDelete | ModeOwner
// manager of the topic - everything but being the owner
ModeManager AccessMode = ModeSub | ModePub | ModePres | ModeShare | ModeDelete
// Default P2P access mode
ModeP2P AccessMode = ModeSub | ModePub | ModePres | ModeDelete
// Invalid mode to indicate an error
ModeInvalid AccessMode = 0x100000
)
func (m AccessMode) MarshalText() ([]byte, error) {
// Need to distinguish between "not set" and "no access"
if m == 0 {
return []byte{'N'}, nil
}
if m == ModeInvalid {
return nil, errors.New("AccessMode invalid")
}
// Banned mode superseeds all other modes
if m&ModeBanned != 0 {
return []byte{'X'}, nil
}
var res = []byte{}
var modes = []byte{'R', 'W', 'P', 'S', 'D', 'O'}
for i, chr := range modes {
if (m & (1 << uint(i))) != 0 {
res = append(res, chr)
}
}
return res, nil
}
func (m *AccessMode) UnmarshalText(b []byte) error {
var m0 AccessMode
for i := 0; i < len(b); i++ {
switch b[i] {
case 'R':
m0 |= ModeSub
case 'W':
m0 |= ModePub
case 'S':
m0 |= ModeShare
case 'D':
m0 |= ModeDelete
case 'P':
m0 |= ModePres
case 'O':
m0 |= ModeOwner
case 'X':
m0 |= ModeBanned
case 'N':
m0 = 0 // N means explicitly no access, all other bits cleared
break
default:
return errors.New("AccessMode: invalid character '" + string(b[i]) + "'")
}
}
if m0&ModeBanned != 0 {
m0 = ModeBanned // clear all other bits
}
*m = m0
return nil
}
func (m AccessMode) String() string {
res, err := m.MarshalText()
if err != nil {
return ""
}
return string(res)
}
func (m AccessMode) MarshalJSON() ([]byte, error) {
res, err := m.MarshalText()
if err != nil {
return nil, err
}
res = append([]byte{'"'}, res...)
return append(res, '"'), nil
}
func (m *AccessMode) UnmarshalJSON(b []byte) error {
if b[0] != '"' || b[len(b)-1] != '"' {
return errors.New("syntax error")
}
return m.UnmarshalText(b[1 : len(b)-1])
}
// Check if grant mode allows all that was requested in want mode
func (grant AccessMode) Check(want AccessMode) bool {
return grant&want == want
}
// Check if grant mode allows want access
func (a AccessMode) IsBanned() bool {
return a&ModeBanned != 0
}
// Relationship between users & topics, stored in database as Subscription
type TopicAccess struct {
User string
Topic string
Want AccessMode
Given AccessMode
}
// Subscription to a topic
type Subscription struct {
ObjHeader
User string // User who has relationship with the topic
Topic string // Topic subscribed to
ModeWant AccessMode // Access applied for
ModeGiven AccessMode // Granted access
ClearedAt *time.Time // User deleted messages older than this time; TODO(gene): topic owner can hard-delete messages
LastSeen map[string]time.Time // Last time when the user joined the topic, by device tag
LastMessageAt *time.Time // 'me' topics only
Private interface{} // User's private data associated with the subscription to topic
// Deserialized ephemeral params
public interface{} // Deserialized public value from topic or user (depends on context)
with string // p2p topics only: id of the other user
}
// SetPublic assigns to public, otherwise not accessible from outside the package
func (s *Subscription) SetPublic(pub interface{}) {
s.public = pub
}
func (s *Subscription) GetPublic() interface{} {
return s.public
}
func (s *Subscription) SetWith(with string) {
s.with = with
}
func (s *Subscription) GetWith() string {
return s.with
}
type perUserData struct {
//owner bool
private interface{}
want AccessMode
given AccessMode
}
// Topic stored in database
type Topic struct {
ObjHeader
State int
Name string
UseBt bool // use bearer token or use ACL
Access DefaultAccess // Default access to topic
LastMessageAt *time.Time
Public interface{}
// Deserialized ephemeral params
owner Uid // first assigned owner
perUser map[Uid]*perUserData // deserialized from Subscription
}
type DefaultAccess struct {
Auth AccessMode
Anon AccessMode
}
//func (t *Topic) GetAccessList() []TopicAccess {
// return t.users
//}
func (t *Topic) GiveAccess(uid Uid, want AccessMode, given AccessMode) {
if t.perUser == nil {
t.perUser = make(map[Uid]*perUserData, 1)
}
pud := t.perUser[uid]
if pud == nil {
pud = &perUserData{}
}
pud.want = want
pud.given = given
t.perUser[uid] = pud
if want&given&ModeOwner != 0 && t.owner.IsZero() {
t.owner = uid
}
}
func (t *Topic) SetPrivate(uid Uid, private interface{}) {
if t.perUser == nil {
t.perUser = make(map[Uid]*perUserData, 1)
}
pud := t.perUser[uid]
if pud == nil {
pud = &perUserData{}
}
pud.private = private
t.perUser[uid] = pud
}
func (t *Topic) GetOwner() Uid {
return t.owner
}
func (t *Topic) GetPrivate(uid Uid) (private interface{}) {
if t.perUser == nil {
return
}
pud := t.perUser[uid]
if pud == nil {
return
}
private = pud.private
return
}
func (t *Topic) GetAccess(uid Uid) (mode AccessMode) {
if t.perUser == nil {
return
}
pud := t.perUser[uid]
if pud == nil {
return
}
mode = pud.given & pud.want
return
}
// Stored {data} message
type Message struct {
ObjHeader
Topic string
From string // UID as string of the user who sent the message, could be empty
Content interface{}
}
// Invites
type InviteAction int
const (
InvJoin InviteAction = iota // an invitation to subscribe
InvAppr // a request to aprove a subscription
InvInfo // info only (request approved or subscribed by a third party), no action required
)
func (a InviteAction) String() string {
switch a {
case InvJoin:
return "join"
case InvAppr:
return "appr"
case InvInfo:
return "info"
}
return ""
}
type BrowseOpt struct {
AscOrder bool // true, if sort in ascending order by time (default - descending)
Since time.Time
Before time.Time
Limit uint
}

1064
server/topic.go Normal file

File diff suppressed because it is too large Load Diff

158
server/wshandler.go Normal file
View File

@ -0,0 +1,158 @@
/******************************************************************************
*
* Copyright (C) 2014 Tinode, All Rights Reserved
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation; either version 3 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program; if not, see <http://www.gnu.org/licenses>.
*
* This code is available under licenses for commercial use.
*
* File : wshandler.go
* Author : Gene Sokolov
* Created : 18-May-2014
*
******************************************************************************
*
* Description :
*
* Handler of websocket connections. See also lphandler.go for long polling.
*
*****************************************************************************/
package main
import (
"github.com/gorilla/websocket"
"log"
"net/http"
"time"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = IDLETIMEOUT
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 1 << 15 // 32K
)
func (sess *Session) readLoop() {
defer func() {
log.Println("serveWebsocket - stop")
sess.close()
globals.sessionStore.Delete(sess.sid)
for _, sub := range sess.subs {
// sub.done is the same as topic.unreg
sub.done <- &sessionLeave{sess: sess, unsub: false}
}
// FIXME(gene): this currently causes occasional panics
close(sess.send)
}()
sess.ws.SetReadLimit(maxMessageSize)
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
sess.ws.SetPongHandler(func(string) error {
sess.ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
sess.remoteAddr = sess.ws.RemoteAddr().String()
for {
// Try reading a ClientComMessage
_, raw, err := sess.ws.ReadMessage()
if err != nil {
log.Println("sess.readLoop: " + err.Error())
return
}
sess.dispatch(raw)
}
}
func (sess *Session) writeLoop() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
sess.close() // break readLoop
}()
for {
select {
case msg, ok := <-sess.send:
if !ok {
// channel closed
return
}
if err := ws_write(sess.ws, websocket.TextMessage, []byte(msg)); err != nil {
log.Println("sess.writeLoop: " + err.Error())
return
}
case <-ticker.C:
if err := ws_write(sess.ws, websocket.PingMessage, []byte{}); err != nil {
log.Println("sess.writeLoop: ping/" + err.Error())
return
}
}
}
}
// Writes a message with the given message type (mt) and payload.
func ws_write(ws *websocket.Conn, mt int, payload []byte) error {
ws.SetWriteDeadline(time.Now().Add(writeWait))
return ws.WriteMessage(mt, payload)
}
// Handles websocket requests from peers
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
func serveWebSocket(wrt http.ResponseWriter, req *http.Request) {
var appid uint32 = 0
if appid, _ = checkApiKey(getApiKey(req)); appid == 0 {
http.Error(wrt, "Missing, invalid or expired API key", http.StatusForbidden)
return
}
if req.Method != "GET" {
http.Error(wrt, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ws, err := upgrader.Upgrade(wrt, req, nil)
if _, ok := err.(websocket.HandshakeError); ok {
http.Error(wrt, "Not a websocket handshake", http.StatusBadRequest)
return
} else if err != nil {
log.Println(err)
return
}
sess := globals.sessionStore.Create(ws, appid)
sess.QueueOut(&ServerComMessage{Ctrl: &MsgServerCtrl{
Code: http.StatusCreated,
Text: http.StatusText(http.StatusCreated),
Params: map[string]interface{}{"ver": VERSION},
Timestamp: time.Now().UTC().Round(time.Millisecond)}})
go sess.writeLoop()
sess.readLoop()
}

3
tinode-db/README.md Normal file
View File

@ -0,0 +1,3 @@
# Create Tinode DB in a local RethinkDB Cluster
Compile then run from the command line. It will reset "tinode" database and fill it with some initial data.

325
tinode-db/data.json Normal file
View File

@ -0,0 +1,325 @@
{
"users": [
{
"createdAt": "-140h",
"email": "alice@example.com",
"passhash": "alice123",
"private": "some comment 123",
"public": "Alice Johnson",
"state": 1,
"status": {
"text": "DND"
},
"username": "alice"
},
{
"createdAt": "-138h",
"email": "bob@example.com",
"passhash": "bob123",
"private": {
"comment": "no comments :)"
},
"public": "Bob Smith",
"state": 1,
"status": "stuff",
"username": "bob"
},
{
"createdAt": "-136h",
"email": "carol@example.com",
"passhash": "carol123",
"private": "more stuff",
"public": "Carol Xmas",
"state": 1,
"status": "ho ho ho",
"username": "carol"
},
{
"createdAt": "-134h",
"email": "dave@example.com",
"passhash": "dave123",
"private": "stuff 123",
"public": "Dave Goliathsson",
"state": 1,
"status": "hiding!",
"username": "dave"
},
{
"createdAt": "-132h",
"email": "eve@example.com",
"passhash": "eve123",
"private": "apples?",
"public": "Eve Adams",
"state": 1,
"username": "eve"
},
{
"createdAt": "-131h",
"email": "frank@example.com",
"passhash": "frank123",
"private": "things, not stuff",
"public": "Frank Sinatra",
"state": 2,
"status": "singing!",
"username": "frank"
}
],
"grouptopics": [
{
"createdAt": "-128h",
"name": "*ABC",
"owner": "carol",
"private": "Carol's private data stash",
"public": "Group ABC w Alice, Bob, Carol (owner)"
},
{
"createdAt": "-126h",
"name": "*ABCDEF",
"owner": "alice",
"public": "Group ABCDEF w Alice (owner), Bob, Carol, Dave, Eve, Frank"
},
{
"createdAt": "-124h",
"name": "*BF",
"public": "Group BF w Bob, Frank, no owner"
}
],
"subscriptions": [
{
"createdAt": "-120h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"topic": "bob",
"user": "alice"
},
{
"createdAt": "-119h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"topic": "carol",
"user": "alice"
},
{
"createdAt": "-118h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"topic": "dave",
"user": "alice"
},
{
"createdAt": "-117h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "apples to oranges",
"topic": "eve",
"user": "alice"
},
{
"createdAt": "-116h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "Frank Frank Frank a-\u003ef",
"topic": "frank",
"user": "alice"
},
{
"createdAt": "-115h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "Alice Jo",
"topic": "alice",
"user": "bob"
},
{
"createdAt": "-114h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "Dave ?",
"topic": "dave",
"user": "bob"
},
{
"createdAt": "-113.5h",
"modeHave": "RWPD",
"modeWant": "RWP",
"private": "Eve Adamsson",
"topic": "eve",
"user": "bob"
},
{
"createdAt": "-113.4h",
"modeHave": "RWPD",
"modeWant": "RW",
"private": "Alice Joha",
"topic": "alice",
"user": "carol"
},
{
"createdAt": "-113h",
"modeHave": "RWPD",
"modeWant": "RWP",
"private": "Alice Johnson",
"topic": "alice",
"user": "dave"
},
{
"createdAt": "-112.8h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "some private info here",
"topic": "bob",
"user": "dave"
},
{
"createdAt": "-112.6h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "Alice Johnson",
"topic": "alice",
"user": "eve"
},
{
"createdAt": "-112.4h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "123",
"topic": "bob",
"user": "eve"
},
{
"createdAt": "-112.2h",
"modeHave": "RWPD",
"modeWant": "RWPD",
"private": "Johnson f-\u003ea",
"topic": "alice",
"user": "frank"
},
{
"createdAt": "-112h",
"modeHave": "RWP",
"modeWant": "RWP",
"private": "My super cool group topic",
"topic": "*ABC",
"user": "alice"
},
{
"createdAt": "-111.9h",
"modeHave": "RWP",
"modeWant": "RWP",
"private": "Wow",
"topic": "*ABC",
"user": "bob"
},
{
"createdAt": "-111.8h",
"modeHave": "RWP",
"modeWant": "RWP",
"private": "Custom group description by Bob",
"topic": "*ABCDEF",
"user": "bob"
},
{
"createdAt": "-111.7h",
"modeHave": "RWP",
"modeWant": "RWP",
"private": "Kirgudu",
"topic": "*ABCDEF",
"user": "carol"
},
{
"createdAt": "-111.6h",
"modeHave": "RWP",
"modeWant": "RWP",
"topic": "*ABCDEF",
"user": "dave"
},
{
"createdAt": "-111.5h",
"modeHave": "RWP",
"modeWant": "RWP",
"topic": "*ABCDEF",
"user": "eve"
},
{
"createdAt": "-111.4h",
"modeHave": "RWP",
"modeWant": "RWP",
"topic": "*ABCDEF",
"user": "frank"
},
{
"createdAt": "-111.3h",
"modeHave": "RWP",
"modeWant": "RWP",
"private": "I'm not the owner",
"topic": "*BF",
"user": "bob"
},
{
"createdAt": "-111.2h",
"modeHave": "RWP",
"modeWant": "RWP",
"private": "I'm not the owner either",
"topic": "*BF",
"user": "frank"
}
],
"messages": [
"Caution: Do not view laser light with remaining eye.",
"Caution: breathing may be hazardous to your health.",
"Celebrate Hannibal Day this year. Take an elephant to lunch.",
"Celibacy is not hereditary.",
"Center 1127 -- It's not just a job, it's an adventure!",
"Center meeting at 4pm in 2C-543",
"Centran manuals are available in 2B-515.",
"Charlie don't surf.",
"Children are hereditary: if your parents didn't have any, neither will you.",
"Clothes make the man. Naked people have little or no influence on society.",
"Club sandwiches, not baby seals.",
"Cocaine is nature's way of saying you make too much money.",
"Cogito Ergo Spud.",
"Cogito cogito ergo cogito sum.",
"Colorless green ideas sleep furiously.",
"Communication is only possible between equals.",
"Computers are not intelligent. They only think they are.",
"Consistency is always easier to defend than correctness.",
"Constants aren't. Variables don't. LISP does. Functions won't. Bytes do.",
"Contains no kung fu, car chases or decapitations.",
"Continental Life. Why do you ask?",
"Convictions cause convicts -- what you believe imprisons you.",
"Core Error - Bus Dumped",
"Could not open 2147478952 framebuffers.",
"Courage is something you can never totally own or totally lose.",
"Cowards die many times before their deaths;/The valiant never taste of death but once.",
"Crazee Edeee, his prices are INSANE!!!",
"Creativity is no substitute for knowing what you are doing.",
"Creditors have much better memories than debtors.",
"Critics are like eunuchs in a harem: they know how it's done, they've seen it done",
"every day, but they're unable to do it themselves. -Brendan Behan",
"Cthulhu Saves! ... in case He's hungry later.",
"Dames is like streetcars -- The oceans is full of 'em. -Archie Bunker",
"Dames lie about anything - just for practice. -Raymond Chandler",
"Damn it, i gotta get outta here!",
"Dangerous knowledge is a little thing.",
"It is certain",
"It is decidedly so",
"Without a doubt",
"Yes definitely",
"You may rely on it",
"As I see it yes",
"Most likely",
"Outlook good",
"Yes",
"Signs point to yes",
"Reply hazy try again",
"Ask again later",
"Better not tell you now",
"Cannot predict now",
"Concentrate and ask again",
"Don't count on it",
"My reply is no",
"My sources say no",
"Outlook not so good",
"Very doubtful"
]
}

371
tinode-db/makedb.go Normal file
View File

@ -0,0 +1,371 @@
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"github.com/tinode/chat/server/store/types"
//"golang.org/x/crypto/bcrypt"
"io/ioutil"
"log"
)
/*****************************************************************************
* Storage schema
*****************************************************************************
* System-accessible tables:
***************************
* 1. Customer (customer of the service)
*****************************
* Customer-accessible tables:
*****************************
* 2. Application (a customer may have multiple applications)
* 3. Application keys (an application may have multiple API keys)
****************************************
* Application/end-user-accessible tables
****************************************
* 4. User (end-user)
* 5. Inbox (a.k.a topics, a list of distinct threads/conversations)
* 6. Messages (persistent store of messages)
* 7. Contacts (a.k.a. ledger, address book)
*****************************************************************************/
type Data struct {
Users []map[string]interface{} `json:"users"`
Grouptopics []map[string]interface{} `json:"grouptopics"`
Subscriptions []map[string]interface{} `json:"subscriptions"`
Messages []string `json:"messages"`
}
var original = Data{
/*
// Header shared by all stored objects
type ObjHeader struct {
Id Uid
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
}
// Stored user
type User struct {
ObjHeader
State int // Unconfirmed, Active, etc.
Username string
Passhash []byte
LastSeen time.Time
Status interface{}
Params interface{}
}
*/
Users: []map[string]interface{}{
{"username": "alice", "state": 1, "passhash": "alice123", "status": map[string]interface{}{"text": "DND"},
"public": "Alice Johnson", "email": "alice@example.com", "private": "some comment 123", "createdAt": "-140h"},
{"username": "bob", "state": 1, "passhash": "bob123",
"public": "Bob Smith", "email": "bob@example.com", "status": "stuff",
"private": map[string]interface{}{"comment": "no comments :)"}, "createdAt": "-138h"},
{"username": "carol", "state": 1, "passhash": "carol123", "status": "ho ho ho",
"public": "Carol Xmas", "email": "carol@example.com", "private": "more stuff", "createdAt": "-136h"},
{"username": "dave", "state": 1, "passhash": "dave123", "status": "hiding!",
"public": "Dave Goliathsson", "email": "dave@example.com", "private": "stuff 123", "createdAt": "-134h"},
{"username": "eve", "state": 1, "passhash": "eve123", // no status here
"public": "Eve Adams", "email": "eve@example.com", "private": "apples?", "createdAt": "-132h"},
{"username": "frank", "state": 2, "passhash": "frank123", "status": "singing!",
"public": "Frank Sinatra", "email": "frank@example.com", "private": "things, not stuff", "createdAt": "-131h"}},
/*
type Topic struct {
ObjHeader
State int
Name string
UseAcl bool
Access struct { // Default access to topic, owner & system = full access
User AccessMode
Anonym AccessMode
}
LastMessageAt *time.Time
Public interface{}
owner Uid // first assigned owner
perUser map[Uid]*perUserData // deserialized from Subscription
}
*/
Grouptopics: []map[string]interface{}{
{"createdAt": "-128h",
"name": "*ABC", // Names will be replaced with random strings
"public": "Group ABC w Alice, Bob, Carol (owner)",
"owner": "carol",
"private": "Carol's private data stash"},
{"createdAt": "-126h",
"name": "*ABCDEF",
"public": "Group ABCDEF w Alice (owner), Bob, Carol, Dave, Eve, Frank",
"owner": "alice"},
{"createdAt": "-124h",
"name": "*BF",
"public": "Group BF w Bob, Frank, no owner"},
},
/*
type Subscription struct {
ObjHeader
User string // User who has relationship with the topic
Topic string // Topic subscribed to
ModeWant AccessMode // Access applied for
ModeGiven AccessMode // Granted access
ClearedAt *time.Time // User deleted messages older than this time; TODO(gene): topic owner can hard-delete messages
LastSeen map[string]time.Time // Last time when the user joined the topic, by device tag
Private interface{} // User's private data associated with the subscription
}
*/
Subscriptions: []map[string]interface{}{
// P2P subscriptions
{"createdAt": "-120h",
"user": "alice",
"topic": "bob",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P}, // No params
{"createdAt": "-119h",
"user": "alice",
"topic": "carol",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P},
{"createdAt": "-118h",
"user": "alice",
"topic": "dave",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P}, // no params
{"createdAt": "-117h",
"user": "alice",
"topic": "eve",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P,
"private": "apples to oranges"},
{"createdAt": "-116h",
"user": "alice",
"topic": "frank",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P, // Alice cannot see Frank's presence
"private": "Frank Frank Frank a->f"},
{"createdAt": "-115h",
"user": "bob",
"topic": "alice",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P,
"private": "Alice Jo"},
{"createdAt": "-114h",
"user": "bob",
"topic": "dave",
"modeWant": types.ModeP2P, // Bob does not want to see Dave's presence
"modeHave": types.ModeP2P,
"private": "Dave ?"},
{"createdAt": "-113.5h",
"user": "bob",
"topic": "eve",
"modeWant": types.ModePublic,
"modeHave": types.ModeP2P,
"private": "Eve Adamsson"},
{"createdAt": "-113.4h",
"user": "carol",
"topic": "alice",
"modeWant": types.ModePubSub,
"modeHave": types.ModeP2P,
"private": "Alice Joha"},
{"createdAt": "-113h",
"user": "dave",
"topic": "alice",
"modeWant": types.ModePublic,
"modeHave": types.ModeP2P,
"private": "Alice Johnson"},
{"createdAt": "-112.8h",
"user": "dave",
"topic": "bob",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P,
"private": "some private info here"},
{"createdAt": "-112.6h",
"user": "eve",
"topic": "alice",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P,
"private": "Alice Johnson"},
{"createdAt": "-112.4h",
"user": "eve",
"topic": "bob",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P,
"private": "123"},
{"createdAt": "-112.2h",
"user": "frank",
"topic": "alice",
"modeWant": types.ModeP2P,
"modeHave": types.ModeP2P,
"private": "Johnson f->a"},
// Gruop topic subscriptions below
{"createdAt": "-112h",
"user": "alice",
"topic": "*ABC",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic,
"private": "My super cool group topic"},
{"createdAt": "-111.9h",
"user": "bob",
"topic": "*ABC",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic,
"private": "Wow"},
/* {"createdAt": nil, // Topic owner, no need to explicitly subscribe
"user": "carol",
"topic": "*ABC",
"modeWant": types.ModeFull,
"modeHave": types.ModeFull,
"private": "Ooga chaka"}, */
/* {"createdAt": nil, // Topic owner, no need to explicitly subscribe
"user": "alice",
"topic": "*ABCDEF",
"modeWant": types.ModeFull,
"modeHave": types.ModeFull,
"private": "ooga ooga"}, */
{"createdAt": "-111.8h",
"user": "bob",
"topic": "*ABCDEF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic,
"private": "Custom group description by Bob"},
{"createdAt": "-111.7h",
"user": "carol",
"topic": "*ABCDEF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic,
"private": "Kirgudu"},
{"createdAt": "-111.6h",
"user": "dave",
"topic": "*ABCDEF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic},
{"createdAt": "-111.5h",
"user": "eve",
"topic": "*ABCDEF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic},
{"createdAt": "-111.4h",
"user": "frank",
"topic": "*ABCDEF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic},
{"createdAt": "-111.3h",
"user": "bob",
"topic": "*BF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic,
"private": "I'm not the owner"},
{"createdAt": "-111.2h",
"user": "frank",
"topic": "*BF",
"modeWant": types.ModePublic,
"modeHave": types.ModePublic,
"private": "I'm not the owner either"},
},
// Messages are generated at random using these strings
Messages: []string{
"Caution: Do not view laser light with remaining eye.",
"Caution: breathing may be hazardous to your health.",
"Celebrate Hannibal Day this year. Take an elephant to lunch.",
"Celibacy is not hereditary.",
"Center 1127 -- It's not just a job, it's an adventure!",
"Center meeting at 4pm in 2C-543",
"Centran manuals are available in 2B-515.",
"Charlie don't surf.",
"Children are hereditary: if your parents didn't have any, neither will you.",
"Clothes make the man. Naked people have little or no influence on society.",
"Club sandwiches, not baby seals.",
"Cocaine is nature's way of saying you make too much money.",
"Cogito Ergo Spud.",
"Cogito cogito ergo cogito sum.",
"Colorless green ideas sleep furiously.",
"Communication is only possible between equals.",
"Computers are not intelligent. They only think they are.",
"Consistency is always easier to defend than correctness.",
"Constants aren't. Variables don't. LISP does. Functions won't. Bytes do.",
"Contains no kung fu, car chases or decapitations.",
"Continental Life. Why do you ask?",
"Convictions cause convicts -- what you believe imprisons you.",
"Core Error - Bus Dumped",
"Could not open 2147478952 framebuffers.",
"Courage is something you can never totally own or totally lose.",
"Cowards die many times before their deaths;/The valiant never taste of death but once.",
"Crazee Edeee, his prices are INSANE!!!",
"Creativity is no substitute for knowing what you are doing.",
"Creditors have much better memories than debtors.",
"Critics are like eunuchs in a harem: they know how it's done, they've seen it done",
"every day, but they're unable to do it themselves. -Brendan Behan",
"Cthulhu Saves! ... in case He's hungry later.",
"Dames is like streetcars -- The oceans is full of 'em. -Archie Bunker",
"Dames lie about anything - just for practice. -Raymond Chandler",
"Damn it, i gotta get outta here!",
"Dangerous knowledge is a little thing.",
"It is certain",
"It is decidedly so",
"Without a doubt",
"Yes definitely",
"You may rely on it",
"As I see it yes",
"Most likely",
"Outlook good",
"Yes",
"Signs point to yes",
"Reply hazy try again",
"Ask again later",
"Better not tell you now",
"Cannot predict now",
"Concentrate and ask again",
"Don't count on it",
"My reply is no",
"My sources say no",
"Outlook not so good",
"Very doubtful"},
}
/*
func passHash(password string) []byte {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return hash
}
*/
func _getRandomString() string {
buf := make([]byte, 9)
_, err := rand.Read(buf)
if err != nil {
panic("getRandomString: failed to generate a random string: " + err.Error())
}
//return base32.StdEncoding.EncodeToString(buf)
return base64.URLEncoding.EncodeToString(buf)
}
func genTopicName() string {
return "grp" + _getRandomString()
}
func main() {
raw, err := ioutil.ReadFile("./data.json")
if err != nil {
log.Fatal(err)
}
var data Data
err = json.Unmarshal(raw, &data)
if err != nil {
log.Fatal(err)
}
gen_rethink(&data)
}

227
tinode-db/rethink.go Normal file
View File

@ -0,0 +1,227 @@
package main
import (
_ "github.com/tinode/chat/server/db/rethinkdb"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"log"
"math/rand"
"time"
)
func gen_rethink(data *Data) {
var err error
log.Println("Opening DB...")
err = store.Open("rethinkdb://localhost:28015/tinode?authKey=&discover=false&maxIdle=&maxOpen=&timeout=&workerId=1&uidkey=la6YsO-bNX_-XIkOqc5Svw==")
if err != nil {
log.Fatal("failed to connect to DB: ", err)
}
defer store.Close()
log.Println("Resetting DB...")
store.ResetDb()
if data.Users == nil {
log.Println("No data provided, stopping")
return
}
nameIndex := make(map[string]string, len(data.Users))
log.Println("Generating users...")
for _, uu := range data.Users {
if uu["createdAt"] != nil {
}
user := types.User{
State: int(uu["state"].(float64)),
Username: uu["username"].(string),
Access: types.DefaultAccess{
Auth: types.ModePublic,
Anon: types.ModeNone,
},
Public: uu["public"],
}
user.CreatedAt = getCreatedTime(uu["createdAt"])
// store.Users.Create will subscribe user to !me topic but won't create a !me topic
if _, err := store.Users.Create(0, &user, uu["passhash"].(string), uu["private"]); err != nil {
log.Fatal(err)
}
nameIndex[user.Username] = user.Id
log.Printf("Created user '%s' as %s (%d)", user.Username, user.Id, user.Uid())
}
log.Println("Generating group topics...")
for _, gt := range data.Grouptopics {
name := genTopicName()
nameIndex[gt["name"].(string)] = name
topic := &types.Topic{Name: name, Public: gt["public"]}
var owner types.Uid
if gt["owner"] != nil {
owner = types.ParseUid(nameIndex[gt["owner"].(string)])
topic.GiveAccess(owner, types.ModeFull, types.ModeFull)
}
topic.CreatedAt = getCreatedTime(gt["createdAt"])
if err = store.Topics.Create(0, topic, owner, gt["private"]); err != nil {
log.Fatal(err)
}
log.Printf("Created topic '%s' as %s", gt["name"].(string), name)
}
log.Println("Generating P2P subscriptions...")
p2pIndex := map[string][]map[string]interface{}{}
for _, ss := range data.Subscriptions {
u1 := ss["user"].(string)
u2 := ss["topic"].(string)
if u2[0] == '*' {
// skip group topics
continue
}
var pair string
var idx int
if u1 < u2 {
pair = u1 + ":" + u2
idx = 0
} else {
pair = u2 + ":" + u1
idx = 1
}
if _, ok := p2pIndex[pair]; !ok {
p2pIndex[pair] = make([]map[string]interface{}, 2)
}
p2pIndex[pair][idx] = ss
}
log.Printf("Collected p2p pairs: %d\n", len(p2pIndex))
for pair, subs := range p2pIndex {
uid1 := types.ParseUid(nameIndex[subs[0]["user"].(string)])
uid2 := types.ParseUid(nameIndex[subs[1]["user"].(string)])
topic := uid1.P2PName(uid2)
created0 := getCreatedTime(subs[0]["createdAt"])
created1 := getCreatedTime(subs[1]["createdAt"])
var s0want, s0given, s1want, s1given types.AccessMode
if err := s0want.UnmarshalText([]byte(subs[0]["modeWant"].(string))); err != nil {
log.Fatal(err)
}
if err := s0given.UnmarshalText([]byte(subs[0]["modeHave"].(string))); err != nil {
log.Fatal(err)
}
if err := s1want.UnmarshalText([]byte(subs[1]["modeWant"].(string))); err != nil {
log.Fatal(err)
}
if err := s1given.UnmarshalText([]byte(subs[1]["modeHave"].(string))); err != nil {
log.Fatal(err)
}
log.Printf("Processing %s (%s), %s, %s", pair, topic, uid1.String(), uid2.String())
err := store.Topics.CreateP2P(0,
&types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: created0},
User: uid1.String(),
Topic: topic,
ModeWant: s0want,
ModeGiven: s0given,
Private: subs[0]["private"]},
&types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: created1},
User: uid2.String(),
Topic: topic,
ModeWant: s1want,
ModeGiven: s1given,
Private: subs[1]["private"]})
if err != nil {
log.Fatal(err)
}
}
log.Println("Generating group subscriptions...")
for _, ss := range data.Subscriptions {
u1 := nameIndex[ss["user"].(string)]
u2 := nameIndex[ss["topic"].(string)]
var want, given types.AccessMode
if err := want.UnmarshalText([]byte(ss["modeWant"].(string))); err != nil {
log.Fatal(err)
}
if err := given.UnmarshalText([]byte(ss["modeHave"].(string))); err != nil {
log.Fatal(err)
}
// Define topic name
name := u2
if !types.ParseUid(u2).IsZero() {
// skip p2p subscriptions
continue
}
log.Printf("Sharing '%s' with '%s'", ss["topic"].(string), ss["user"].(string))
if err = store.Subs.Create(0, &types.Subscription{
ObjHeader: types.ObjHeader{CreatedAt: getCreatedTime(ss["createdAt"])},
User: u1,
Topic: name,
ModeWant: want,
ModeGiven: given,
Private: ss["private"]}); err != nil {
log.Fatal(err)
}
}
log.Println("Generating messages...")
rand.Seed(time.Now().UnixNano())
for i := 0; i < 80; i++ {
sub := data.Subscriptions[rand.Intn(len(data.Subscriptions))]
topic := nameIndex[sub["topic"].(string)]
from := types.ParseUid(nameIndex[sub["user"].(string)])
if uid := types.ParseUid(topic); !uid.IsZero() {
topic = uid.P2PName(from)
}
str := data.Messages[rand.Intn(len(data.Messages))]
timestamp := time.Now().UTC().Round(time.Millisecond).Add(time.Second * time.Duration(-1*rand.Intn(3600*24*4))) // spread over the past 4 days
if err = store.Messages.Save(0, &types.Message{
ObjHeader: types.ObjHeader{CreatedAt: timestamp},
Topic: topic,
From: from.String(),
Content: str}); err != nil {
log.Fatal(err)
}
log.Printf("Message from '%s' to '%s' (%s) '%s'", from.String(), topic, nameIndex[sub["topic"].(string)], str)
}
}
func getCreatedTime(v interface{}) time.Time {
if v != nil {
dd, err := time.ParseDuration(v.(string))
if err == nil {
return time.Now().UTC().Round(time.Millisecond).Add(dd)
} else {
log.Fatal(err)
}
}
return time.Time{}
}

3
utils/README.md Normal file
View File

@ -0,0 +1,3 @@
# API key generator
See source for details

129
utils/generate.go Normal file
View File

@ -0,0 +1,129 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"encoding/base64"
"encoding/binary"
"flag"
"fmt"
)
var hmac_salt = []byte{
0x4f, 0xbd, 0x77, 0xfe, 0xb6, 0x18, 0x81, 0x6e,
0xe0, 0xe2, 0x6d, 0xef, 0x1b, 0xac, 0xc6, 0x46,
0x1e, 0xfe, 0x14, 0xcd, 0x6d, 0xd1, 0x3f, 0x23,
0xd7, 0x79, 0x28, 0x5d, 0x27, 0x0e, 0x02, 0x3e}
// Generate API key
// Composition:
// [1:algorithm version][4:appid][2:key sequence][1:isRoot][16:signature] = 24 bytes
// convertible to base64 without padding
// All integers are little-endian
func main() {
var appId = flag.Int("appid", 0, "App ID to sign")
var version = flag.Int("sequence", 1, "Sequential number of the API key")
var isRoot = flag.Int("isroot", 0, "Is this a root API key?")
var apikey = flag.String("validate", "", "API key to validate")
flag.Parse()
if *appId != 0 {
generate(*appId, *version, *isRoot)
} else if *apikey != "" {
validate(*apikey)
} else {
flag.Usage()
}
}
const (
APIKEY_VERSION = 1
APIKEY_APPID = 4
APIKEY_SEQUENCE = 2
APIKEY_WHO = 1
APIKEY_SIGNATURE = 16
APIKEY_LENGTH = APIKEY_VERSION + APIKEY_APPID + APIKEY_SEQUENCE + APIKEY_WHO + APIKEY_SIGNATURE
)
func generate(appId, sequence, isRoot int) {
var data [APIKEY_LENGTH]byte
// [1:algorithm version][4:appid][2:key sequence][1:isRoot]
data[0] = 1 // default algorithm
binary.LittleEndian.PutUint32(data[APIKEY_VERSION:], uint32(appId))
binary.LittleEndian.PutUint16(data[APIKEY_VERSION+APIKEY_APPID:], uint16(sequence))
data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE] = uint8(isRoot)
hasher := hmac.New(md5.New, hmac_salt)
hasher.Write(data[:APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO])
signature := hasher.Sum(nil)
copy(data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO:], signature)
var strIsRoot string
if isRoot == 1 {
strIsRoot = "ROOT"
} else {
strIsRoot = "ordinary"
}
fmt.Printf("API key v%d for (%d:%d), %s: %s\n", 1, appId, sequence, strIsRoot,
base64.URLEncoding.EncodeToString(data[:]))
}
func validate(apikey string) {
var version uint8
var appid uint32
var sequence uint16
var isRoot uint8
var strIsRoot string
defer func() {
if appid == 0 {
fmt.Println("INVALID: ", apikey)
} else {
fmt.Printf("Valid (%d:%d), %s\n", appid, sequence, strIsRoot)
}
}()
if declen := base64.URLEncoding.DecodedLen(len(apikey)); declen != APIKEY_LENGTH {
return
}
data, err := base64.URLEncoding.DecodeString(apikey)
if err != nil {
fmt.Println("failed to decode.base64 appid ", err)
return
}
buf := bytes.NewReader(data)
binary.Read(buf, binary.LittleEndian, &version)
if version != 1 {
fmt.Println("unknown appid signature algorithm ", data[0])
return
}
hasher := hmac.New(md5.New, hmac_salt)
hasher.Write(data[:APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO])
signature := hasher.Sum(nil)
if !bytes.Equal(data[APIKEY_VERSION+APIKEY_APPID+APIKEY_SEQUENCE+APIKEY_WHO:], signature) {
fmt.Println("invalid appid signature ", data, signature)
return
}
// [1:algorithm version][4:appid][2:key sequence][1:isRoot]
binary.Read(buf, binary.LittleEndian, &appid)
binary.Read(buf, binary.LittleEndian, &sequence)
binary.Read(buf, binary.LittleEndian, &isRoot)
if isRoot == 1 {
strIsRoot = "ROOT"
} else {
strIsRoot = "ordinary"
}
}