mirror of
https://github.com/tinode/chat.git
synced 2025-03-14 10:05:07 +00:00
initial commit of version 0.4
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
server/server
|
||||
server/db/rethinkdb/rethinkdb_data
|
||||
tinode-db/tinode-db
|
||||
utils/utils
|
455
README.md
Normal file
455
README.md
Normal 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 "␡" `"\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
|
135
android/lib/src/main/java/com/tinode/Tinode.java
Normal file
135
android/lib/src/main/java/com/tinode/Tinode.java
Normal 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;
|
||||
}
|
||||
}
|
136
android/lib/src/main/java/com/tinode/rest/Request.java
Normal file
136
android/lib/src/main/java/com/tinode/rest/Request.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
103
android/lib/src/main/java/com/tinode/rest/Rest.java
Normal file
103
android/lib/src/main/java/com/tinode/rest/Rest.java
Normal 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;
|
||||
}
|
||||
}
|
52
android/lib/src/main/java/com/tinode/rest/model/Contact.java
Normal file
52
android/lib/src/main/java/com/tinode/rest/model/Contact.java
Normal 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) + "...";
|
||||
}
|
||||
}
|
||||
}
|
940
android/lib/src/main/java/com/tinode/streaming/Connection.java
Normal file
940
android/lib/src/main/java/com/tinode/streaming/Connection.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
105
android/lib/src/main/java/com/tinode/streaming/MeTopic.java
Normal file
105
android/lib/src/main/java/com/tinode/streaming/MeTopic.java
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
222
android/lib/src/main/java/com/tinode/streaming/Topic.java
Normal file
222
android/lib/src/main/java/com/tinode/streaming/Topic.java
Normal 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<?> 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);
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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";
|
||||
}
|
||||
}
|
111
android/lib/src/main/java/com/tinode/streaming/model/Utils.java
Normal file
111
android/lib/src/main/java/com/tinode/streaming/model/Utils.java
Normal 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;
|
||||
}
|
||||
}
|
4
android/sample/TinodeChatDemo/.gitignore
vendored
Normal file
4
android/sample/TinodeChatDemo/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
.DS_Store
|
1
android/sample/TinodeChatDemo/app/.gitignore
vendored
Normal file
1
android/sample/TinodeChatDemo/app/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
80
android/sample/TinodeChatDemo/app/app.iml
Normal file
80
android/sample/TinodeChatDemo/app/app.iml
Normal 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>
|
||||
|
34
android/sample/TinodeChatDemo/app/build.gradle
Normal file
34
android/sample/TinodeChatDemo/app/build.gradle
Normal 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'
|
||||
}
|
@ -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>
|
@ -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 |
@ -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" />
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
11
android/sample/TinodeChatDemo/app/src/main/res/menu/main.xml
Normal file
11
android/sample/TinodeChatDemo/app/src/main/res/menu/main.xml
Normal 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>
|
@ -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>
|
@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<dimen name="activity_horizontal_margin">8dp</dimen>
|
||||
<dimen name="activity_vertical_margin">8dp</dimen>
|
||||
</resources>
|
@ -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>
|
@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
</resources>
|
16
android/sample/TinodeChatDemo/build.gradle
Normal file
16
android/sample/TinodeChatDemo/build.gradle
Normal 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
192
server/auth.go
Normal 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
102
server/crud.go
Normal 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
628
server/datamodel.go
Normal 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
|
||||
}
|
755
server/db/rethinkdb/adapter.go
Normal file
755
server/db/rethinkdb/adapter.go
Normal 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{})
|
||||
}
|
69
server/db/rethinkdb/utils.go
Normal file
69
server/db/rethinkdb/utils.go
Normal 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
650
server/hub.go
Normal 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
161
server/lphandler.go
Normal 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
112
server/main.go
Normal 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
370
server/presence.go
Normal 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
607
server/session.go
Normal 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
109
server/sessionstore.go
Normal 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
|
||||
}
|
1130
server/static/js/tinode-0.4.js
Normal file
1130
server/static/js/tinode-0.4.js
Normal file
File diff suppressed because it is too large
Load Diff
736
server/static/samples/chatdemo.html
Normal file
736
server/static/samples/chatdemo.html
Normal 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">
|
||||
<span class="glyphicon glyphicon-user"></span>
|
||||
</button>
|
||||
<button class="btn btn-default btn-xs" type="button" id="loginConfigButton">
|
||||
<span class="glyphicon glyphicon-cog"></span>
|
||||
</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">
|
||||
<span class="glyphicon glyphicon-cog"></span>
|
||||
</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">
|
||||
<span class="glyphicon glyphicon-cog"></span>
|
||||
</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="{"name": "value"}">
|
||||
</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">
|
||||
<span class="glyphicon glyphicon-cog"></span>
|
||||
</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> <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> & <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>
|
32
server/static/samples/polychat.html
Normal file
32
server/static/samples/polychat.html
Normal 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>
|
59
server/store/adapter/adapter.go
Normal file
59
server/store/adapter/adapter.go
Normal 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
365
server/store/store.go
Normal 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
542
server/store/types/types.go
Normal 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
1064
server/topic.go
Normal file
File diff suppressed because it is too large
Load Diff
158
server/wshandler.go
Normal file
158
server/wshandler.go
Normal 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
3
tinode-db/README.md
Normal 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
325
tinode-db/data.json
Normal 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
371
tinode-db/makedb.go
Normal 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
227
tinode-db/rethink.go
Normal 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
3
utils/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# API key generator
|
||||
|
||||
See source for details
|
129
utils/generate.go
Normal file
129
utils/generate.go
Normal 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"
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user