replace zxq.co/ripple/hanayo

This commit is contained in:
Alicia
2019-02-23 13:29:15 +00:00
commit c3d206c173
5871 changed files with 1353715 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
// Package internals has methods that suit none of the API packages.
package internals
import "github.com/valyala/fasthttp"
var statusResp = []byte(`{ "status": 1 }`)
// Status is used for checking the API is up by the ripple website, on the status page.
func Status(c *fasthttp.RequestCtx) {
c.Write(statusResp)
}

View File

@@ -0,0 +1,144 @@
package app
import (
"encoding/json"
"fmt"
"reflect"
"regexp"
"strings"
"unsafe"
"github.com/valyala/fasthttp"
"zxq.co/ripple/rippleapi/common"
)
// Method wraps an API method to a HandlerFunc.
func Method(f func(md common.MethodData) common.CodeMessager, privilegesNeeded ...int) fasthttp.RequestHandler {
return func(c *fasthttp.RequestCtx) {
initialCaretaker(c, f, privilegesNeeded...)
}
}
func initialCaretaker(c *fasthttp.RequestCtx, f func(md common.MethodData) common.CodeMessager, privilegesNeeded ...int) {
var doggoTags []string
qa := c.Request.URI().QueryArgs()
var token string
var bearerToken bool
switch {
case len(c.Request.Header.Peek("X-Ripple-Token")) > 0:
token = string(c.Request.Header.Peek("X-Ripple-Token"))
case strings.HasPrefix(string(c.Request.Header.Peek("Authorization")), "Bearer "):
token = strings.TrimPrefix(string(c.Request.Header.Peek("Authorization")), "Bearer ")
bearerToken = true
case len(qa.Peek("token")) > 0:
token = string(qa.Peek("token"))
case len(qa.Peek("k")) > 0:
token = string(qa.Peek("k"))
default:
token = string(c.Request.Header.Cookie("rt"))
}
md := common.MethodData{
DB: db,
Ctx: c,
Doggo: doggo,
R: red,
}
if token != "" {
var (
tokenReal common.Token
exists bool
)
if bearerToken {
tokenReal, exists = BearerToken(token, db)
} else {
tokenReal, exists = GetTokenFull(token, db)
}
if exists {
md.User = tokenReal
doggoTags = append(doggoTags, "authorised")
}
}
// log into datadog that this is an hanayo request
if b2s(c.Request.Header.Peek("H-Key")) == cf.HanayoKey && b2s(c.UserAgent()) == "hanayo" {
doggoTags = append(doggoTags, "hanayo")
}
doggo.Incr("requests.v1", doggoTags, 1)
missingPrivileges := 0
for _, privilege := range privilegesNeeded {
if uint64(md.User.TokenPrivileges)&uint64(privilege) == 0 {
missingPrivileges |= privilege
}
}
if missingPrivileges != 0 {
c.SetStatusCode(401)
mkjson(c, common.SimpleResponse(401, "You don't have the privilege(s): "+common.Privileges(missingPrivileges).String()+"."))
return
}
resp := f(md)
if md.HasQuery("pls200") {
c.SetStatusCode(200)
} else {
c.SetStatusCode(resp.GetCode())
}
if md.HasQuery("callback") {
c.Response.Header.Add("Content-Type", "application/javascript; charset=utf-8")
} else {
c.Response.Header.Add("Content-Type", "application/json; charset=utf-8")
}
mkjson(c, resp)
}
// Very restrictive, but this way it shouldn't completely fuck up.
var callbackJSONP = regexp.MustCompile(`^[a-zA-Z_\$][a-zA-Z0-9_\$]*$`)
// mkjson auto indents json, and wraps json into a jsonp callback if specified by the request.
// then writes to the RequestCtx the data.
func mkjson(c *fasthttp.RequestCtx, data interface{}) {
exported, err := json.MarshalIndent(data, "", "\t")
if err != nil {
fmt.Println(err)
exported = []byte(`{ "code": 500, "message": "something has gone really really really really really really wrong." }`)
}
cb := string(c.URI().QueryArgs().Peek("callback"))
willcb := cb != "" &&
len(cb) < 100 &&
callbackJSONP.MatchString(cb)
if willcb {
c.Write([]byte("/**/ typeof " + cb + " === 'function' && " + cb + "("))
}
c.Write(exported)
if willcb {
c.Write([]byte(");"))
}
}
// b2s converts byte slice to a string without memory allocation.
// See https://groups.google.com/forum/#!msg/Golang-Nuts/ENgbUzYvCuU/90yGx7GUAgAJ .
//
// Note it may break if string and/or slice header will change
// in the future go versions.
func b2s(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
// s2b converts string to a byte slice without memory allocation.
//
// Note it may break if string and/or slice header will change
// in the future go versions.
func s2b(s string) []byte {
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
bh := reflect.SliceHeader{
Data: sh.Data,
Len: sh.Len,
Cap: sh.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
}

View File

@@ -0,0 +1,133 @@
package peppy
import (
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/thehowl/go-osuapi"
"github.com/valyala/fasthttp"
"zxq.co/ripple/rippleapi/common"
)
// GetBeatmap retrieves general beatmap information.
func GetBeatmap(c *fasthttp.RequestCtx, db *sqlx.DB) {
var whereClauses []string
var params []interface{}
limit := strconv.Itoa(common.InString(1, query(c, "limit"), 500, 500))
// since value is not stored, silently ignore
if query(c, "s") != "" {
whereClauses = append(whereClauses, "beatmaps.beatmapset_id = ?")
params = append(params, query(c, "s"))
}
if query(c, "b") != "" {
whereClauses = append(whereClauses, "beatmaps.beatmap_id = ?")
params = append(params, query(c, "b"))
// b is unique, so change limit to 1
limit = "1"
}
// creator is not stored, silently ignore u and type
if query(c, "m") != "" {
m := genmode(query(c, "m"))
if m == "std" {
// Since STD beatmaps are converted, all of the diffs must be != 0
for _, i := range modes {
whereClauses = append(whereClauses, "beatmaps.difficulty_"+i+" != 0")
}
} else {
whereClauses = append(whereClauses, "beatmaps.difficulty_"+m+" != 0")
if query(c, "a") == "1" {
whereClauses = append(whereClauses, "beatmaps.difficulty_std = 0")
}
}
}
if query(c, "h") != "" {
whereClauses = append(whereClauses, "beatmaps.beatmap_md5 = ?")
params = append(params, query(c, "h"))
}
where := strings.Join(whereClauses, " AND ")
if where != "" {
where = "WHERE " + where
}
rows, err := db.Query(`SELECT
beatmapset_id, beatmap_id, ranked, hit_length,
song_name, beatmap_md5, ar, od, bpm, playcount,
passcount, max_combo, difficulty_std, difficulty_taiko, difficulty_ctb, difficulty_mania,
latest_update
FROM beatmaps `+where+" ORDER BY id DESC LIMIT "+limit,
params...)
if err != nil {
common.Err(c, err)
json(c, 200, defaultResponse)
return
}
var bms []osuapi.Beatmap
for rows.Next() {
var (
bm osuapi.Beatmap
rawRankedStatus int
rawName string
rawLastUpdate common.UnixTimestamp
diffs [4]float64
)
err := rows.Scan(
&bm.BeatmapSetID, &bm.BeatmapID, &rawRankedStatus, &bm.HitLength,
&rawName, &bm.FileMD5, &bm.ApproachRate, &bm.OverallDifficulty, &bm.BPM, &bm.Playcount,
&bm.Passcount, &bm.MaxCombo, &diffs[0], &diffs[1], &diffs[2], &diffs[3],
&rawLastUpdate,
)
if err != nil {
common.Err(c, err)
continue
}
bm.TotalLength = bm.HitLength
bm.LastUpdate = osuapi.MySQLDate(rawLastUpdate)
if rawRankedStatus >= 2 {
bm.ApprovedDate = osuapi.MySQLDate(rawLastUpdate)
}
// zero value of ApprovedStatus == osuapi.StatusPending, so /shrug
bm.Approved = rippleToOsuRankedStatus[rawRankedStatus]
bm.Artist, bm.Title, bm.DiffName = parseDiffName(rawName)
for i, diffVal := range diffs {
if diffVal != 0 {
bm.Mode = osuapi.Mode(i)
bm.DifficultyRating = diffVal
break
}
}
bms = append(bms, bm)
}
json(c, 200, bms)
}
var rippleToOsuRankedStatus = map[int]osuapi.ApprovedStatus{
0: osuapi.StatusPending,
1: osuapi.StatusWIP, // it means "needs updating", as the one in the db needs to be updated, but whatever
2: osuapi.StatusRanked,
3: osuapi.StatusApproved,
4: osuapi.StatusQualified,
5: osuapi.StatusLoved,
}
// buggy diffname parser
func parseDiffName(name string) (author string, title string, diffName string) {
parts := strings.SplitN(name, " - ", 2)
author = parts[0]
if len(parts) > 1 {
title = parts[1]
if s := strings.Index(title, " ["); s != -1 {
diffName = title[s+2:]
if len(diffName) != 0 && diffName[len(diffName)-1] == ']' {
diffName = diffName[:len(diffName)-1]
}
title = title[:s]
}
}
return
}

View File

@@ -0,0 +1,75 @@
package peppy
import (
"database/sql"
_json "encoding/json"
"strconv"
"github.com/jmoiron/sqlx"
"github.com/valyala/fasthttp"
"zxq.co/ripple/rippleapi/common"
)
var modes = []string{"std", "taiko", "ctb", "mania"}
var defaultResponse = []struct{}{}
func genmode(m string) string {
i := genmodei(m)
return modes[i]
}
func genmodei(m string) int {
v := common.Int(m)
if v > 3 || v < 0 {
v = 0
}
return v
}
func rankable(m string) bool {
x := genmodei(m)
return x != 2
}
func genUser(c *fasthttp.RequestCtx, db *sqlx.DB) (string, string) {
var whereClause string
var p string
// used in second case of switch
s, err := strconv.Atoi(query(c, "u"))
switch {
// We know for sure that it's an username.
case query(c, "type") == "string":
whereClause = "users.username_safe = ?"
p = common.SafeUsername(query(c, "u"))
// It could be an user ID, so we look for an user with that username first.
case err == nil:
err = db.QueryRow("SELECT id FROM users WHERE id = ? LIMIT 1", s).Scan(&p)
if err == sql.ErrNoRows {
// If no user with that userID were found, assume username.
whereClause = "users.username_safe = ?"
p = common.SafeUsername(query(c, "u"))
} else {
// An user with that userID was found. Thus it's an userID.
whereClause = "users.id = ?"
}
// u contains letters, so it's an username.
default:
whereClause = "users.username_safe = ?"
p = common.SafeUsername(query(c, "u"))
}
return whereClause, p
}
func query(c *fasthttp.RequestCtx, s string) string {
return string(c.QueryArgs().Peek(s))
}
func json(c *fasthttp.RequestCtx, code int, data interface{}) {
c.SetStatusCode(code)
d, err := _json.Marshal(data)
if err != nil {
panic(err)
}
c.Write(d)
}

View File

@@ -0,0 +1,12 @@
// Package peppy implements the osu! API as defined on the osu-api repository wiki (https://github.com/ppy/osu-api/wiki).
package peppy
import (
"github.com/jmoiron/sqlx"
"github.com/valyala/fasthttp"
)
// GetMatch retrieves general match information.
func GetMatch(c *fasthttp.RequestCtx, db *sqlx.DB) {
json(c, 200, defaultResponse)
}

View File

@@ -0,0 +1,98 @@
package peppy
import (
"database/sql"
"strconv"
"strings"
"zxq.co/ripple/rippleapi/common"
"github.com/jmoiron/sqlx"
"github.com/valyala/fasthttp"
"gopkg.in/thehowl/go-osuapi.v1"
"zxq.co/x/getrank"
)
// GetScores retrieve information about the top 100 scores of a specified beatmap.
func GetScores(c *fasthttp.RequestCtx, db *sqlx.DB) {
if query(c, "b") == "" {
json(c, 200, defaultResponse)
return
}
var beatmapMD5 string
err := db.Get(&beatmapMD5, "SELECT beatmap_md5 FROM beatmaps WHERE beatmap_id = ? LIMIT 1", query(c, "b"))
switch {
case err == sql.ErrNoRows:
json(c, 200, defaultResponse)
return
case err != nil:
common.Err(c, err)
json(c, 200, defaultResponse)
return
}
var sb = "scores.score"
if rankable(query(c, "m")) {
sb = "scores.pp"
}
var (
extraWhere string
extraParams []interface{}
)
if query(c, "u") != "" {
w, p := genUser(c, db)
extraWhere = "AND " + w
extraParams = append(extraParams, p)
}
mods := common.Int(query(c, "mods"))
rows, err := db.Query(`
SELECT
scores.id, scores.score, users.username, scores.300_count, scores.100_count,
scores.50_count, scores.misses_count, scores.gekis_count, scores.katus_count,
scores.max_combo, scores.full_combo, scores.mods, users.id, scores.time, scores.pp,
scores.accuracy
FROM scores
INNER JOIN users ON users.id = scores.userid
WHERE scores.completed = '3'
AND users.privileges & 1 > 0
AND scores.beatmap_md5 = ?
AND scores.play_mode = ?
AND scores.mods & ? = ?
`+extraWhere+`
ORDER BY `+sb+` DESC LIMIT `+strconv.Itoa(common.InString(1, query(c, "limit"), 100, 50)),
append([]interface{}{beatmapMD5, genmodei(query(c, "m")), mods, mods}, extraParams...)...)
if err != nil {
common.Err(c, err)
json(c, 200, defaultResponse)
return
}
var results []osuapi.GSScore
for rows.Next() {
var (
s osuapi.GSScore
fullcombo bool
mods int
date common.UnixTimestamp
accuracy float64
)
err := rows.Scan(
&s.ScoreID, &s.Score.Score, &s.Username, &s.Count300, &s.Count100,
&s.Count50, &s.CountMiss, &s.CountGeki, &s.CountKatu,
&s.MaxCombo, &fullcombo, &mods, &s.UserID, &date, &s.PP,
&accuracy,
)
if err != nil {
if err != sql.ErrNoRows {
common.Err(c, err)
}
continue
}
s.FullCombo = osuapi.OsuBool(fullcombo)
s.Mods = osuapi.Mods(mods)
s.Date = osuapi.MySQLDate(date)
s.Rank = strings.ToUpper(getrank.GetRank(osuapi.Mode(genmodei(query(c, "m"))), s.Mods,
accuracy, s.Count300, s.Count100, s.Count50, s.CountMiss))
results = append(results, s)
}
json(c, 200, results)
return
}

View File

@@ -0,0 +1,64 @@
// Package peppy implements the osu! API as defined on the osu-api repository wiki (https://github.com/ppy/osu-api/wiki).
package peppy
import (
"database/sql"
"fmt"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/thehowl/go-osuapi"
"github.com/valyala/fasthttp"
"gopkg.in/redis.v5"
"zxq.co/ripple/ocl"
"zxq.co/ripple/rippleapi/common"
)
// R is a redis client.
var R *redis.Client
// GetUser retrieves general user information.
func GetUser(c *fasthttp.RequestCtx, db *sqlx.DB) {
if query(c, "u") == "" {
json(c, 200, defaultResponse)
return
}
var user osuapi.User
whereClause, p := genUser(c, db)
whereClause = "WHERE " + whereClause
mode := genmode(query(c, "m"))
err := db.QueryRow(fmt.Sprintf(
`SELECT
users.id, users.username,
users_stats.playcount_%s, users_stats.ranked_score_%s, users_stats.total_score_%s,
users_stats.pp_%s, users_stats.avg_accuracy_%s,
users_stats.country
FROM users
LEFT JOIN users_stats ON users_stats.id = users.id
%s
LIMIT 1`,
mode, mode, mode, mode, mode, whereClause,
), p).Scan(
&user.UserID, &user.Username,
&user.Playcount, &user.RankedScore, &user.TotalScore,
&user.PP, &user.Accuracy,
&user.Country,
)
if err != nil {
json(c, 200, defaultResponse)
if err != sql.ErrNoRows {
common.Err(c, err)
}
return
}
user.Rank = int(R.ZRevRank("ripple:leaderboard:"+mode, strconv.Itoa(user.UserID)).Val()) + 1
user.CountryRank = int(R.ZRevRank("ripple:leaderboard:"+mode+":"+strings.ToLower(user.Country), strconv.Itoa(user.UserID)).Val()) + 1
user.Level = ocl.GetLevelPrecise(user.TotalScore)
json(c, 200, []osuapi.User{user})
}

View File

@@ -0,0 +1,95 @@
package peppy
import (
"fmt"
"strings"
"github.com/jmoiron/sqlx"
"github.com/valyala/fasthttp"
"gopkg.in/thehowl/go-osuapi.v1"
"zxq.co/ripple/rippleapi/common"
"zxq.co/x/getrank"
)
// GetUserRecent retrieves an user's recent scores.
func GetUserRecent(c *fasthttp.RequestCtx, db *sqlx.DB) {
getUserX(c, db, "ORDER BY scores.time DESC", common.InString(1, query(c, "limit"), 50, 10))
}
// GetUserBest retrieves an user's best scores.
func GetUserBest(c *fasthttp.RequestCtx, db *sqlx.DB) {
var sb string
if rankable(query(c, "m")) {
sb = "scores.pp"
} else {
sb = "scores.score"
}
getUserX(c, db, "AND completed = '3' ORDER BY "+sb+" DESC", common.InString(1, query(c, "limit"), 100, 10))
}
func getUserX(c *fasthttp.RequestCtx, db *sqlx.DB, orderBy string, limit int) {
whereClause, p := genUser(c, db)
sqlQuery := fmt.Sprintf(
`SELECT
beatmaps.beatmap_id, scores.score, scores.max_combo,
scores.300_count, scores.100_count, scores.50_count,
scores.gekis_count, scores.katus_count, scores.misses_count,
scores.full_combo, scores.mods, users.id, scores.time,
scores.pp, scores.accuracy
FROM scores
LEFT JOIN beatmaps ON beatmaps.beatmap_md5 = scores.beatmap_md5
LEFT JOIN users ON scores.userid = users.id
WHERE %s AND scores.play_mode = ? AND users.privileges & 1 > 0
%s
LIMIT %d`, whereClause, orderBy, limit,
)
scores := make([]osuapi.GUSScore, 0, limit)
m := genmodei(query(c, "m"))
rows, err := db.Query(sqlQuery, p, m)
if err != nil {
json(c, 200, defaultResponse)
common.Err(c, err)
return
}
for rows.Next() {
var (
curscore osuapi.GUSScore
rawTime common.UnixTimestamp
acc float64
fc bool
mods int
bid *int
)
err := rows.Scan(
&bid, &curscore.Score.Score, &curscore.MaxCombo,
&curscore.Count300, &curscore.Count100, &curscore.Count50,
&curscore.CountGeki, &curscore.CountKatu, &curscore.CountMiss,
&fc, &mods, &curscore.UserID, &rawTime,
&curscore.PP, &acc,
)
if err != nil {
json(c, 200, defaultResponse)
common.Err(c, err)
return
}
if bid == nil {
curscore.BeatmapID = 0
} else {
curscore.BeatmapID = *bid
}
curscore.FullCombo = osuapi.OsuBool(fc)
curscore.Mods = osuapi.Mods(mods)
curscore.Date = osuapi.MySQLDate(rawTime)
curscore.Rank = strings.ToUpper(getrank.GetRank(
osuapi.Mode(m),
curscore.Mods,
acc,
curscore.Count300,
curscore.Count100,
curscore.Count50,
curscore.CountMiss,
))
scores = append(scores, curscore)
}
json(c, 200, scores)
}

View File

@@ -0,0 +1,19 @@
package app
import (
"github.com/jmoiron/sqlx"
"github.com/valyala/fasthttp"
)
// PeppyMethod generates a method for the peppyapi
func PeppyMethod(a func(c *fasthttp.RequestCtx, db *sqlx.DB)) fasthttp.RequestHandler {
return func(c *fasthttp.RequestCtx) {
doggo.Incr("requests.peppy", nil, 1)
c.Response.Header.Add("Content-Type", "application/json; charset=utf-8")
// I have no idea how, but I manged to accidentally string the first 4
// letters of the alphabet into a single function call.
a(c, db)
}
}

View File

@@ -0,0 +1,106 @@
package app
import (
"bytes"
"errors"
"fmt"
"time"
"github.com/buaazp/fasthttprouter"
"github.com/jmoiron/sqlx"
"github.com/valyala/fasthttp"
"zxq.co/ripple/rippleapi/common"
)
type router struct {
r *fasthttprouter.Router
}
func (r router) Method(path string, f func(md common.MethodData) common.CodeMessager, privilegesNeeded ...int) {
r.r.GET(path, wrap(Method(f, privilegesNeeded...)))
}
func (r router) POSTMethod(path string, f func(md common.MethodData) common.CodeMessager, privilegesNeeded ...int) {
r.r.POST(path, wrap(Method(f, privilegesNeeded...)))
}
func (r router) Peppy(path string, a func(c *fasthttp.RequestCtx, db *sqlx.DB)) {
r.r.GET(path, wrap(PeppyMethod(a)))
}
func (r router) GET(path string, handle fasthttp.RequestHandler) {
r.r.GET(path, wrap(handle))
}
func (r router) PlainGET(path string, handle fasthttp.RequestHandler) {
r.r.GET(path, handle)
}
const (
// \x1b is escape code for ESC
// <ESC>[<n>m is escape sequence for a certain colour
// no IP is written out because of the hundreds of possible ways to pass IPs
// to a request when using a reverse proxy
// this is partly inspired from gin, though made even more simplistic.
fmtString = "%s | %15s |\x1b[%sm %3d \x1b[0m %-7s %s\n"
// a kind of human readable RFC3339
timeFormat = "2006-01-02 15:04:05"
// color reference
// http://misc.flogisoft.com/bash/tip_colors_and_formatting
colorOk = "42" // green
colorError = "41" // red
)
// wrap returns a function that wraps around handle, providing middleware
// functionality to apply to all API calls, which is to say:
// - logging
// - panic recovery (reporting to sentry)
// - gzipping
func wrap(handle fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(c *fasthttp.RequestCtx) {
start := time.Now()
doggo.Incr("requests", nil, 1)
defer func() {
if rval := recover(); rval != nil {
var err error
switch rval := rval.(type) {
case string:
err = errors.New(rval)
case error:
err = rval
default:
err = fmt.Errorf("%v - %#v", rval, rval)
}
common.Err(c, err)
c.SetStatusCode(500)
c.SetBodyString(`{ "code": 500, "message": "something really bad happened" }`)
}
// switch color to colorError if statusCode is in [500;600)
color := colorOk
statusCode := c.Response.StatusCode()
if statusCode >= 500 && statusCode < 600 {
color = colorError
}
if bytes.Contains(c.Request.Header.Peek("Accept-Encoding"), s2b("gzip")) {
c.Response.Header.Add("Content-Encoding", "gzip")
c.Response.Header.Add("Vary", "Accept-Encoding")
b := c.Response.Body()
c.Response.ResetBody()
fasthttp.WriteGzip(c.Response.BodyWriter(), b)
}
// print stuff
fmt.Printf(
fmtString,
time.Now().Format(timeFormat),
time.Since(start).String(),
color,
statusCode,
c.Method(),
c.Path(),
)
}()
handle(c)
}
}

View File

@@ -0,0 +1,168 @@
package app
import (
"fmt"
"time"
"github.com/DataDog/datadog-go/statsd"
fhr "github.com/buaazp/fasthttprouter"
"github.com/getsentry/raven-go"
"github.com/jmoiron/sqlx"
"gopkg.in/redis.v5"
"zxq.co/ripple/rippleapi/app/internals"
"zxq.co/ripple/rippleapi/app/peppy"
"zxq.co/ripple/rippleapi/app/v1"
"zxq.co/ripple/rippleapi/app/websockets"
"zxq.co/ripple/rippleapi/common"
)
var (
db *sqlx.DB
cf common.Conf
doggo *statsd.Client
red *redis.Client
)
// Start begins taking HTTP connections.
func Start(conf common.Conf, dbO *sqlx.DB) *fhr.Router {
db = dbO
cf = conf
rawRouter := fhr.New()
r := router{rawRouter}
// TODO: add back gzip
// TODO: add logging
// TODO: add sentry panic recovering
// sentry
if conf.SentryDSN != "" {
ravenClient, err := raven.New(conf.SentryDSN)
ravenClient.SetRelease(common.Version)
if err != nil {
fmt.Println(err)
} else {
// r.Use(Recovery(ravenClient, false))
common.RavenClient = ravenClient
}
}
// datadog
var err error
doggo, err = statsd.New("127.0.0.1:8125")
if err != nil {
fmt.Println(err)
}
doggo.Namespace = "api."
// redis
red = redis.NewClient(&redis.Options{
Addr: conf.RedisAddr,
Password: conf.RedisPassword,
DB: conf.RedisDB,
})
peppy.R = red
// token updater
go tokenUpdater(db)
// start websocket
websockets.Start(red, db)
// start load achievements
go v1.LoadAchievementsEvery(db, time.Minute*10)
// peppyapi
{
r.Peppy("/api/get_user", peppy.GetUser)
r.Peppy("/api/get_match", peppy.GetMatch)
r.Peppy("/api/get_user_recent", peppy.GetUserRecent)
r.Peppy("/api/get_user_best", peppy.GetUserBest)
r.Peppy("/api/get_scores", peppy.GetScores)
r.Peppy("/api/get_beatmaps", peppy.GetBeatmap)
}
// v1 API
{
r.POSTMethod("/api/v1/tokens/self/delete", v1.TokenSelfDeletePOST)
// Auth-free API endpoints (public data)
r.Method("/api/v1/ping", v1.PingGET)
r.Method("/api/v1/surprise_me", v1.SurpriseMeGET)
r.Method("/api/v1/users", v1.UsersGET)
r.Method("/api/v1/users/whatid", v1.UserWhatsTheIDGET)
r.Method("/api/v1/users/full", v1.UserFullGET)
r.Method("/api/v1/users/achievements", v1.UserAchievementsGET)
r.Method("/api/v1/users/userpage", v1.UserUserpageGET)
r.Method("/api/v1/users/lookup", v1.UserLookupGET)
r.Method("/api/v1/users/scores/best", v1.UserScoresBestGET)
r.Method("/api/v1/users/scores/recent", v1.UserScoresRecentGET)
r.Method("/api/v1/badges", v1.BadgesGET)
r.Method("/api/v1/badges/members", v1.BadgeMembersGET)
r.Method("/api/v1/beatmaps", v1.BeatmapGET)
r.Method("/api/v1/leaderboard", v1.LeaderboardGET)
r.Method("/api/v1/tokens", v1.TokenGET)
r.Method("/api/v1/users/self", v1.UserSelfGET)
r.Method("/api/v1/tokens/self", v1.TokenSelfGET)
r.Method("/api/v1/blog/posts", v1.BlogPostsGET)
r.Method("/api/v1/scores", v1.ScoresGET)
r.Method("/api/v1/beatmaps/rank_requests/status", v1.BeatmapRankRequestsStatusGET)
// ReadConfidential privilege required
r.Method("/api/v1/friends", v1.FriendsGET, common.PrivilegeReadConfidential)
r.Method("/api/v1/friends/with", v1.FriendsWithGET, common.PrivilegeReadConfidential)
r.Method("/api/v1/users/self/donor_info", v1.UsersSelfDonorInfoGET, common.PrivilegeReadConfidential)
r.Method("/api/v1/users/self/favourite_mode", v1.UsersSelfFavouriteModeGET, common.PrivilegeReadConfidential)
r.Method("/api/v1/users/self/settings", v1.UsersSelfSettingsGET, common.PrivilegeReadConfidential)
// Write privilege required
r.POSTMethod("/api/v1/friends/add", v1.FriendsAddPOST, common.PrivilegeWrite)
r.POSTMethod("/api/v1/friends/del", v1.FriendsDelPOST, common.PrivilegeWrite)
r.POSTMethod("/api/v1/users/self/settings", v1.UsersSelfSettingsPOST, common.PrivilegeWrite)
r.POSTMethod("/api/v1/users/self/userpage", v1.UserSelfUserpagePOST, common.PrivilegeWrite)
r.POSTMethod("/api/v1/beatmaps/rank_requests", v1.BeatmapRankRequestsSubmitPOST, common.PrivilegeWrite)
// Admin: RAP
r.POSTMethod("/api/v1/rap/log", v1.RAPLogPOST)
// Admin: beatmap
r.POSTMethod("/api/v1/beatmaps/set_status", v1.BeatmapSetStatusPOST, common.PrivilegeBeatmap)
r.Method("/api/v1/beatmaps/ranked_frozen_full", v1.BeatmapRankedFrozenFullGET, common.PrivilegeBeatmap)
// Admin: user managing
r.POSTMethod("/api/v1/users/manage/set_allowed", v1.UserManageSetAllowedPOST, common.PrivilegeManageUser)
r.POSTMethod("/api/v1/users/edit", v1.UserEditPOST, common.PrivilegeManageUser)
// M E T A
// E T "wow thats so meta"
// T E -- the one who said "wow thats so meta"
// A T E M
r.Method("/api/v1/meta/restart", v1.MetaRestartGET, common.PrivilegeAPIMeta)
r.Method("/api/v1/meta/up_since", v1.MetaUpSinceGET, common.PrivilegeAPIMeta)
r.Method("/api/v1/meta/update", v1.MetaUpdateGET, common.PrivilegeAPIMeta)
// User Managing + meta
r.POSTMethod("/api/v1/tokens/fix_privileges", v1.TokenFixPrivilegesPOST,
common.PrivilegeManageUser, common.PrivilegeAPIMeta)
}
// Websocket API
{
r.PlainGET("/api/v1/ws", websockets.WebsocketV1Entry)
}
// in the new osu-web, the old endpoints are also in /v1 it seems. So /shrug
{
r.Peppy("/api/v1/get_user", peppy.GetUser)
r.Peppy("/api/v1/get_match", peppy.GetMatch)
r.Peppy("/api/v1/get_user_recent", peppy.GetUserRecent)
r.Peppy("/api/v1/get_user_best", peppy.GetUserBest)
r.Peppy("/api/v1/get_scores", peppy.GetScores)
r.Peppy("/api/v1/get_beatmaps", peppy.GetBeatmap)
}
r.GET("/api/status", internals.Status)
rawRouter.NotFound = v1.Handle404
return rawRouter
}

View File

@@ -0,0 +1,112 @@
package app
import (
"crypto/md5"
"crypto/sha256"
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"zxq.co/ripple/rippleapi/common"
)
// GetTokenFull retrieves an user ID and their token privileges knowing their API token.
func GetTokenFull(token string, db *sqlx.DB) (common.Token, bool) {
var (
t common.Token
tokenPrivsRaw uint64
userPrivsRaw uint64
priv8 bool
)
err := db.QueryRow(`SELECT
t.id, t.user, t.privileges, t.private, u.privileges
FROM tokens t
LEFT JOIN users u ON u.id = t.user
WHERE token = ? LIMIT 1`,
fmt.Sprintf("%x", md5.Sum([]byte(token)))).
Scan(
&t.ID, &t.UserID, &tokenPrivsRaw, &priv8, &userPrivsRaw,
)
updateTokens <- t.ID
if priv8 {
// all privileges, they'll get removed by canOnly anyway.
tokenPrivsRaw = (common.PrivilegeBeatmap << 1) - 1
}
t.UserPrivileges = common.UserPrivileges(userPrivsRaw)
t.TokenPrivileges = common.Privileges(tokenPrivsRaw).CanOnly(t.UserPrivileges)
switch {
case err == sql.ErrNoRows:
return common.Token{}, false
case err != nil:
panic(err)
default:
t.Value = token
return t, true
}
}
var updateTokens = make(chan int, 100)
func tokenUpdater(db *sqlx.DB) {
for {
// prepare a queue of tokens to update.
tokensToUpdate := make([]int, 0, 50)
AwaitLoop:
for {
// if we got ten, move on and update
if len(tokensToUpdate) >= 50 {
break
}
// if we ain't got any, add what we get straight from updateTokens
if len(tokensToUpdate) == 0 {
x := <-updateTokens
tokensToUpdate = append(tokensToUpdate, x)
continue
}
// otherwise, wait from updateTokens with a timeout of 10 seconds
select {
case x := <-updateTokens:
tokensToUpdate = append(tokensToUpdate, x)
case <-time.After(10 * time.Second):
// wondering what this means?
// https://golang.org/ref/spec#Break_statements
break AwaitLoop
}
}
q, a, _ := sqlx.In("UPDATE tokens SET last_updated = ? WHERE id IN (?)", time.Now().Unix(), tokensToUpdate)
q = db.Rebind(q)
_, err := db.Exec(q, a...)
if err != nil {
fmt.Println(err)
}
}
}
// BearerToken parses a Token given in the Authorization header, with the
// Bearer prefix.
func BearerToken(token string, db *sqlx.DB) (common.Token, bool) {
var x struct {
Scope string
Extra int
}
db.Get(&x, "SELECT scope, extra FROM osin_access WHERE access_token = ? LIMIT 1", fmt.Sprintf("%x", sha256.Sum256([]byte(token))))
if x.Extra == 0 {
return common.Token{}, false
}
var privs uint64
db.Get(&privs, "SELECT privileges FROM users WHERE id = ? LIMIT 1", x.Extra)
var t common.Token
t.ID = -1
t.UserID = x.Extra
t.Value = token
t.UserPrivileges = common.UserPrivileges(privs)
t.TokenPrivileges = common.OAuthPrivileges(x.Scope).CanOnly(t.UserPrivileges)
return t, true
}

View File

@@ -0,0 +1,29 @@
package v1
import (
"encoding/json"
"github.com/valyala/fasthttp"
"zxq.co/ripple/rippleapi/common"
)
type response404 struct {
common.ResponseBase
Cats string `json:"cats"`
}
// Handle404 handles requests with no implemented handlers.
func Handle404(c *fasthttp.RequestCtx) {
c.Response.Header.Add("X-Real-404", "yes")
data, err := json.MarshalIndent(response404{
ResponseBase: common.ResponseBase{
Code: 404,
},
Cats: surpriseMe(),
}, "", "\t")
if err != nil {
panic(err)
}
c.SetStatusCode(404)
c.Write(data)
}

View File

@@ -0,0 +1,82 @@
package v1
import (
"database/sql"
"zxq.co/ripple/rippleapi/common"
)
type singleBadge struct {
ID int `json:"id,omitempty"`
Name string `json:"name"`
Icon string `json:"icon"`
}
type multiBadgeData struct {
common.ResponseBase
Badges []singleBadge `json:"badges"`
}
// BadgesGET retrieves all the badges on this ripple instance.
func BadgesGET(md common.MethodData) common.CodeMessager {
var (
r multiBadgeData
rows *sql.Rows
err error
)
if md.Query("id") != "" {
rows, err = md.DB.Query("SELECT id, name, icon FROM badges WHERE id = ? LIMIT 1", md.Query("id"))
} else {
rows, err = md.DB.Query("SELECT id, name, icon FROM badges " + common.Paginate(md.Query("p"), md.Query("l"), 50))
}
if err != nil {
md.Err(err)
return Err500
}
defer rows.Close()
for rows.Next() {
nb := singleBadge{}
err = rows.Scan(&nb.ID, &nb.Name, &nb.Icon)
if err != nil {
md.Err(err)
}
r.Badges = append(r.Badges, nb)
}
if err := rows.Err(); err != nil {
md.Err(err)
}
r.ResponseBase.Code = 200
return r
}
type badgeMembersData struct {
common.ResponseBase
Members []userData `json:"members"`
}
// BadgeMembersGET retrieves the people who have a certain badge.
func BadgeMembersGET(md common.MethodData) common.CodeMessager {
i := common.Int(md.Query("id"))
if i == 0 {
return ErrMissingField("id")
}
var members badgeMembersData
err := md.DB.Select(&members.Members, `SELECT users.id, users.username, register_datetime, users.privileges,
latest_activity, users_stats.username_aka,
users_stats.country
FROM user_badges ub
INNER JOIN users ON users.id = ub.user
INNER JOIN users_stats ON users_stats.id = ub.user
WHERE badge = ?
ORDER BY id ASC `+common.Paginate(md.Query("p"), md.Query("l"), 50), i)
if err != nil {
md.Err(err)
return Err500
}
members.Code = 200
return members
}

View File

@@ -0,0 +1,228 @@
package v1
import (
"database/sql"
"zxq.co/ripple/rippleapi/common"
)
type difficulty struct {
STD float64 `json:"std"`
Taiko float64 `json:"taiko"`
CTB float64 `json:"ctb"`
Mania float64 `json:"mania"`
}
type beatmap struct {
BeatmapID int `json:"beatmap_id"`
BeatmapsetID int `json:"beatmapset_id"`
BeatmapMD5 string `json:"beatmap_md5"`
SongName string `json:"song_name"`
AR float32 `json:"ar"`
OD float32 `json:"od"`
Difficulty float64 `json:"difficulty"`
Diff2 difficulty `json:"difficulty2"` // fuck nyo
MaxCombo int `json:"max_combo"`
HitLength int `json:"hit_length"`
Ranked int `json:"ranked"`
RankedStatusFrozen int `json:"ranked_status_frozen"`
LatestUpdate common.UnixTimestamp `json:"latest_update"`
}
type beatmapResponse struct {
common.ResponseBase
beatmap
}
type beatmapSetResponse struct {
common.ResponseBase
Beatmaps []beatmap `json:"beatmaps"`
}
type beatmapSetStatusData struct {
BeatmapsetID int `json:"beatmapset_id"`
BeatmapID int `json:"beatmap_id"`
RankedStatus int `json:"ranked_status"`
Frozen int `json:"frozen"`
}
// BeatmapSetStatusPOST changes the ranked status of a beatmap, and whether
// the beatmap ranked status is frozen. Or freezed. Freezed best meme 2k16
func BeatmapSetStatusPOST(md common.MethodData) common.CodeMessager {
var req beatmapSetStatusData
md.Unmarshal(&req)
var miss []string
if req.BeatmapsetID <= 0 && req.BeatmapID <= 0 {
miss = append(miss, "beatmapset_id or beatmap_id")
}
if len(miss) != 0 {
return ErrMissingField(miss...)
}
if req.Frozen != 0 && req.Frozen != 1 {
return common.SimpleResponse(400, "frozen status must be either 0 or 1")
}
if req.RankedStatus > 4 || -1 > req.RankedStatus {
return common.SimpleResponse(400, "ranked status must be 5 < x < -2")
}
param := req.BeatmapsetID
if req.BeatmapID != 0 {
err := md.DB.QueryRow("SELECT beatmapset_id FROM beatmaps WHERE beatmap_id = ? LIMIT 1", req.BeatmapID).Scan(&param)
switch {
case err == sql.ErrNoRows:
return common.SimpleResponse(404, "That beatmap could not be found!")
case err != nil:
md.Err(err)
return Err500
}
}
md.DB.Exec(`UPDATE beatmaps
SET ranked = ?, ranked_status_freezed = ?
WHERE beatmapset_id = ?`, req.RankedStatus, req.Frozen, param)
if req.BeatmapID > 0 {
md.Ctx.Request.URI().QueryArgs().SetUint("bb", req.BeatmapID)
} else {
md.Ctx.Request.URI().QueryArgs().SetUint("s", req.BeatmapsetID)
}
return getMultipleBeatmaps(md)
}
// BeatmapGET retrieves a beatmap.
func BeatmapGET(md common.MethodData) common.CodeMessager {
beatmapID := common.Int(md.Query("b"))
if beatmapID != 0 {
return getBeatmapSingle(md, beatmapID)
}
return getMultipleBeatmaps(md)
}
const baseBeatmapSelect = `
SELECT
beatmap_id, beatmapset_id, beatmap_md5,
song_name, ar, od, difficulty_std, difficulty_taiko,
difficulty_ctb, difficulty_mania, max_combo,
hit_length, ranked, ranked_status_freezed,
latest_update
FROM beatmaps
`
func getMultipleBeatmaps(md common.MethodData) common.CodeMessager {
sort := common.Sort(md, common.SortConfiguration{
Allowed: []string{
"beatmapset_id",
"beatmap_id",
"id",
"ar",
"od",
"difficulty_std",
"difficulty_taiko",
"difficulty_ctb",
"difficulty_mania",
"max_combo",
"latest_update",
"playcount",
"passcount",
},
Default: "id DESC",
Table: "beatmaps",
})
pm := md.Ctx.Request.URI().QueryArgs().PeekMulti
where := common.
Where("song_name = ?", md.Query("song_name")).
Where("ranked_status_freezed = ?", md.Query("ranked_status_frozen"), "0", "1").
In("beatmap_id", pm("bb")...).
In("beatmapset_id", pm("s")...).
In("beatmap_md5", pm("md5")...)
rows, err := md.DB.Query(baseBeatmapSelect+
where.Clause+" "+sort+" "+
common.Paginate(md.Query("p"), md.Query("l"), 50), where.Params...)
if err != nil {
md.Err(err)
return Err500
}
var r beatmapSetResponse
for rows.Next() {
var b beatmap
err = rows.Scan(
&b.BeatmapID, &b.BeatmapsetID, &b.BeatmapMD5,
&b.SongName, &b.AR, &b.OD, &b.Diff2.STD, &b.Diff2.Taiko,
&b.Diff2.CTB, &b.Diff2.Mania, &b.MaxCombo,
&b.HitLength, &b.Ranked, &b.RankedStatusFrozen,
&b.LatestUpdate,
)
b.Difficulty = b.Diff2.STD
if err != nil {
md.Err(err)
continue
}
r.Beatmaps = append(r.Beatmaps, b)
}
r.Code = 200
return r
}
func getBeatmapSingle(md common.MethodData, beatmapID int) common.CodeMessager {
var b beatmap
err := md.DB.QueryRow(baseBeatmapSelect+"WHERE beatmap_id = ? LIMIT 1", beatmapID).Scan(
&b.BeatmapID, &b.BeatmapsetID, &b.BeatmapMD5,
&b.SongName, &b.AR, &b.OD, &b.Diff2.STD, &b.Diff2.Taiko,
&b.Diff2.CTB, &b.Diff2.Mania, &b.MaxCombo,
&b.HitLength, &b.Ranked, &b.RankedStatusFrozen,
&b.LatestUpdate,
)
b.Difficulty = b.Diff2.STD
switch {
case err == sql.ErrNoRows:
return common.SimpleResponse(404, "That beatmap could not be found!")
case err != nil:
md.Err(err)
return Err500
}
var r beatmapResponse
r.Code = 200
r.beatmap = b
return r
}
type beatmapReduced struct {
BeatmapID int `json:"beatmap_id"`
BeatmapsetID int `json:"beatmapset_id"`
BeatmapMD5 string `json:"beatmap_md5"`
Ranked int `json:"ranked"`
RankedStatusFrozen int `json:"ranked_status_frozen"`
}
type beatmapRankedFrozenFullResponse struct {
common.ResponseBase
Beatmaps []beatmapReduced `json:"beatmaps"`
}
// BeatmapRankedFrozenFullGET retrieves all beatmaps with a certain
// ranked_status_freezed
func BeatmapRankedFrozenFullGET(md common.MethodData) common.CodeMessager {
rows, err := md.DB.Query(`
SELECT beatmap_id, beatmapset_id, beatmap_md5, ranked, ranked_status_freezed
FROM beatmaps
WHERE ranked_status_freezed = '1'
`)
if err != nil {
md.Err(err)
return Err500
}
var r beatmapRankedFrozenFullResponse
for rows.Next() {
var b beatmapReduced
err = rows.Scan(&b.BeatmapID, &b.BeatmapsetID, &b.BeatmapMD5, &b.Ranked, &b.RankedStatusFrozen)
if err != nil {
md.Err(err)
continue
}
r.Beatmaps = append(r.Beatmaps, b)
}
r.Code = 200
return r
}

View File

@@ -0,0 +1,159 @@
package v1
import (
"database/sql"
"encoding/json"
"strconv"
"time"
"zxq.co/ripple/rippleapi/common"
"zxq.co/ripple/rippleapi/limit"
)
type rankRequestsStatusResponse struct {
common.ResponseBase
QueueSize int `json:"queue_size"`
MaxPerUser int `json:"max_per_user"`
Submitted int `json:"submitted"`
SubmittedByUser *int `json:"submitted_by_user,omitempty"`
CanSubmit *bool `json:"can_submit,omitempty"`
NextExpiration *time.Time `json:"next_expiration"`
}
// BeatmapRankRequestsStatusGET gets the current status for beatmap ranking requests.
func BeatmapRankRequestsStatusGET(md common.MethodData) common.CodeMessager {
c := common.GetConf()
rows, err := md.DB.Query("SELECT userid, time FROM rank_requests WHERE time > ? ORDER BY id ASC LIMIT "+strconv.Itoa(c.RankQueueSize), time.Now().Add(-time.Hour*24).Unix())
if err != nil {
md.Err(err)
return Err500
}
var r rankRequestsStatusResponse
// if it's not auth-free access and we have got ReadConfidential, we can
// know if this user can submit beatmaps or not.
hasConfid := md.ID() != 0 && md.User.TokenPrivileges&common.PrivilegeReadConfidential > 0
if hasConfid {
r.SubmittedByUser = new(int)
}
isFirst := true
for rows.Next() {
var (
user int
timestamp common.UnixTimestamp
)
err := rows.Scan(&user, &timestamp)
if err != nil {
md.Err(err)
continue
}
// if the user submitted this rank request, increase the number of
// rank requests submitted by this user
if user == md.ID() && r.SubmittedByUser != nil {
(*r.SubmittedByUser)++
}
// also, if this is the first result, it means it will be the next to
// expire.
if isFirst {
x := time.Time(timestamp)
r.NextExpiration = &x
isFirst = false
}
r.Submitted++
}
r.QueueSize = c.RankQueueSize
r.MaxPerUser = c.BeatmapRequestsPerUser
if hasConfid {
x := r.Submitted < r.QueueSize && *r.SubmittedByUser < r.MaxPerUser
r.CanSubmit = &x
}
r.Code = 200
return r
}
type submitRequestData struct {
ID int `json:"id"`
SetID int `json:"set_id"`
}
// BeatmapRankRequestsSubmitPOST submits a new beatmap for ranking approval.
func BeatmapRankRequestsSubmitPOST(md common.MethodData) common.CodeMessager {
var d submitRequestData
err := md.Unmarshal(&d)
if err != nil {
return ErrBadJSON
}
// check json data is present
if d.ID == 0 && d.SetID == 0 {
return ErrMissingField("id|set_id")
}
// you've been rate limited
if !limit.NonBlockingRequest("rankrequest:u:"+strconv.Itoa(md.ID()), 5) {
return common.SimpleResponse(429, "You may only try to request 5 beatmaps per minute.")
}
// find out from BeatmapRankRequestsStatusGET if we can submit beatmaps.
statusRaw := BeatmapRankRequestsStatusGET(md)
status, ok := statusRaw.(rankRequestsStatusResponse)
if !ok {
// if it's not a rankRequestsStatusResponse, it means it's an error
return statusRaw
}
if !*status.CanSubmit {
return common.SimpleResponse(403, "It's not possible to do a rank request at this time.")
}
w := common.
Where("beatmap_id = ?", strconv.Itoa(d.ID)).Or().
Where("beatmapset_id = ?", strconv.Itoa(d.SetID))
var ranked int
err = md.DB.QueryRow("SELECT ranked FROM beatmaps "+w.Clause+" LIMIT 1", w.Params...).Scan(&ranked)
if ranked >= 2 {
return common.SimpleResponse(406, "That beatmap is already ranked.")
}
switch err {
case nil:
// move on
case sql.ErrNoRows:
data, _ := json.Marshal(d)
md.R.Publish("lets:beatmap_updates", string(data))
default:
md.Err(err)
return Err500
}
// type and value of beatmap rank request
t := "b"
v := d.ID
if d.SetID != 0 {
t = "s"
v = d.SetID
}
err = md.DB.QueryRow("SELECT 1 FROM rank_requests WHERE bid = ? AND type = ? AND time > ?",
v, t, time.Now().Add(-time.Hour*24).Unix()).Scan(new(int))
// error handling
switch err {
case sql.ErrNoRows:
break
case nil:
// we're returning a success because if the request was already sent in the past 24
// hours, it's as if the user submitted it.
return BeatmapRankRequestsStatusGET(md)
default:
md.Err(err)
return Err500
}
_, err = md.DB.Exec(
"INSERT INTO rank_requests (userid, bid, type, time, blacklisted) VALUES (?, ?, ?, ?, 0)",
md.ID(), v, t, time.Now().Unix())
if err != nil {
md.Err(err)
return Err500
}
return BeatmapRankRequestsStatusGET(md)
}

View File

@@ -0,0 +1,184 @@
package v1
import (
"bytes"
"encoding/gob"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"strings"
"time"
"zxq.co/ripple/rippleapi/common"
)
// This basically proxies requests from Medium's API and is used on Ripple's
// home page to display the latest blog posts.
type mediumResp struct {
Success bool `json:"success"`
Payload struct {
Posts []mediumPost `json:"posts"`
References struct {
User map[string]mediumUser
} `json:"references"`
} `json:"payload"`
}
type mediumPost struct {
ID string `json:"id"`
CreatorID string `json:"creatorId"`
Title string `json:"title"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
Virtuals mediumPostVirtuals `json:"virtuals"`
ImportedURL string `json:"importedUrl"`
UniqueSlug string `json:"uniqueSlug"`
}
type mediumUser struct {
UserID string `json:"userId"`
Name string `json:"name"`
Username string `json:"username"`
}
type mediumPostVirtuals struct {
Subtitle string `json:"subtitle"`
WordCount int `json:"wordCount"`
ReadingTime float64 `json:"readingTime"`
}
// there's gotta be a better way
type blogPost struct {
ID string `json:"id"`
Creator blogUser `json:"creator"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ImportedURL string `json:"imported_url"`
UniqueSlug string `json:"unique_slug"`
Snippet string `json:"snippet"`
WordCount int `json:"word_count"`
ReadingTime float64 `json:"reading_time"`
}
type blogUser struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Username string `json:"username"`
}
type blogPostsResponse struct {
common.ResponseBase
Posts []blogPost `json:"posts"`
}
// consts for the medium API
const (
mediumAPIResponsePrefix = `])}while(1);</x>`
mediumAPIAllPosts = `https://blog.ripple.moe/latest?format=json`
)
func init() {
gob.Register([]blogPost{})
}
// BlogPostsGET retrieves the latest blog posts on the Ripple blog.
func BlogPostsGET(md common.MethodData) common.CodeMessager {
// check if posts are cached in redis
res := md.R.Get("api:blog_posts").Val()
if res != "" {
// decode values
posts := make([]blogPost, 0, 20)
err := gob.NewDecoder(strings.NewReader(res)).Decode(&posts)
if err != nil {
md.Err(err)
return Err500
}
// create response and return
var r blogPostsResponse
r.Code = 200
r.Posts = blogLimit(posts, md.Query("l"))
return r
}
// get data from medium api
resp, err := http.Get(mediumAPIAllPosts)
if err != nil {
md.Err(err)
return Err500
}
// read body and trim the prefix
all, err := ioutil.ReadAll(resp.Body)
if err != nil {
md.Err(err)
return Err500
}
all = bytes.TrimPrefix(all, []byte(mediumAPIResponsePrefix))
// unmarshal into response struct
var mResp mediumResp
err = json.Unmarshal(all, &mResp)
if err != nil {
md.Err(err)
return Err500
}
if !mResp.Success {
md.Err(errors.New("medium api call is not successful"))
return Err500
}
// create posts slice and fill it up with converted posts from the medium
// API
posts := make([]blogPost, len(mResp.Payload.Posts))
for idx, mp := range mResp.Payload.Posts {
var p blogPost
// convert structs
p.ID = mp.ID
p.Title = mp.Title
p.CreatedAt = time.Unix(0, mp.CreatedAt*1000000)
p.UpdatedAt = time.Unix(0, mp.UpdatedAt*1000000)
p.ImportedURL = mp.ImportedURL
p.UniqueSlug = mp.UniqueSlug
cr := mResp.Payload.References.User[mp.CreatorID]
p.Creator.UserID = cr.UserID
p.Creator.Name = cr.Name
p.Creator.Username = cr.Username
p.Snippet = mp.Virtuals.Subtitle
p.WordCount = mp.Virtuals.WordCount
p.ReadingTime = mp.Virtuals.ReadingTime
posts[idx] = p
}
// save in redis
bb := new(bytes.Buffer)
err = gob.NewEncoder(bb).Encode(posts)
if err != nil {
md.Err(err)
return Err500
}
md.R.Set("api:blog_posts", bb.Bytes(), time.Minute*5)
var r blogPostsResponse
r.Code = 200
r.Posts = blogLimit(posts, md.Query("l"))
return r
}
func blogLimit(posts []blogPost, s string) []blogPost {
i := common.Int(s)
if i >= len(posts) || i < 1 {
return posts
}
return posts[:i]
}

View File

@@ -0,0 +1,21 @@
package v1
import (
"strings"
"zxq.co/ripple/rippleapi/common"
)
// Boilerplate errors
var (
Err500 = common.SimpleResponse(500, "An error occurred. Trying again may work. If it doesn't, yell at this Ripple instance admin and tell them to fix the API.")
ErrBadJSON = common.SimpleResponse(400, "Your JSON for this request is invalid.")
)
// ErrMissingField generates a response to a request when some fields in the JSON are missing.
func ErrMissingField(missingFields ...string) common.CodeMessager {
return common.ResponseBase{
Code: 422, // http://stackoverflow.com/a/10323055/5328069
Message: "Missing parameters: " + strings.Join(missingFields, ", ") + ".",
}
}

View File

@@ -0,0 +1,202 @@
package v1
import (
"database/sql"
"zxq.co/ripple/rippleapi/common"
)
type friendData struct {
userData
IsMutual bool `json:"is_mutual"`
}
type friendsGETResponse struct {
common.ResponseBase
Friends []friendData `json:"friends"`
}
// FriendsGET is the API request handler for GET /friends.
// It retrieves an user's friends, and whether the friendship is mutual or not.
func FriendsGET(md common.MethodData) common.CodeMessager {
var myFrienders []int
myFriendersRaw, err := md.DB.Query("SELECT user1 FROM users_relationships WHERE user2 = ?", md.ID())
if err != nil {
md.Err(err)
return Err500
}
defer myFriendersRaw.Close()
for myFriendersRaw.Next() {
var i int
err := myFriendersRaw.Scan(&i)
if err != nil {
md.Err(err)
continue
}
myFrienders = append(myFrienders, i)
}
if err := myFriendersRaw.Err(); err != nil {
md.Err(err)
}
myFriendsQuery := `
SELECT
users.id, users.username, users.register_datetime, users.privileges, users.latest_activity,
users_stats.username_aka,
users_stats.country
FROM users_relationships
LEFT JOIN users
ON users_relationships.user2 = users.id
LEFT JOIN users_stats
ON users_relationships.user2=users_stats.id
WHERE users_relationships.user1=?
`
myFriendsQuery += common.Sort(md, common.SortConfiguration{
Allowed: []string{
"id",
"username",
"latest_activity",
},
Default: "users.id asc",
Table: "users",
}) + "\n"
results, err := md.DB.Query(myFriendsQuery+common.Paginate(md.Query("p"), md.Query("l"), 100), md.ID())
if err != nil {
md.Err(err)
return Err500
}
var myFriends []friendData
defer results.Close()
for results.Next() {
newFriend := friendPuts(md, results)
for _, uid := range myFrienders {
if uid == newFriend.ID {
newFriend.IsMutual = true
break
}
}
myFriends = append(myFriends, newFriend)
}
if err := results.Err(); err != nil {
md.Err(err)
}
r := friendsGETResponse{}
r.Code = 200
r.Friends = myFriends
return r
}
func friendPuts(md common.MethodData, row *sql.Rows) (user friendData) {
var err error
err = row.Scan(&user.ID, &user.Username, &user.RegisteredOn, &user.Privileges, &user.LatestActivity, &user.UsernameAKA, &user.Country)
if err != nil {
md.Err(err)
return
}
return
}
type friendsWithResponse struct {
common.ResponseBase
Friends bool `json:"friend"`
Mutual bool `json:"mutual"`
}
// FriendsWithGET checks the current user is friends with the one passed in the request path.
func FriendsWithGET(md common.MethodData) common.CodeMessager {
var r friendsWithResponse
r.Code = 200
uid := common.Int(md.Query("id"))
if uid == 0 {
return r
}
err := md.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM users_relationships WHERE user1 = ? AND user2 = ? LIMIT 1), EXISTS(SELECT 1 FROM users_relationships WHERE user2 = ? AND user1 = ? LIMIT 1)", md.ID(), uid, md.ID(), uid).Scan(&r.Friends, &r.Mutual)
if err != sql.ErrNoRows && err != nil {
md.Err(err)
return Err500
}
if !r.Friends {
r.Mutual = false
}
return r
}
// FriendsAddPOST adds an user to the friends.
func FriendsAddPOST(md common.MethodData) common.CodeMessager {
var u struct {
User int `json:"user"`
}
md.Unmarshal(&u)
return addFriend(md, u.User)
}
func addFriend(md common.MethodData, u int) common.CodeMessager {
if md.ID() == u {
return common.SimpleResponse(406, "Just so you know: you can't add yourself to your friends.")
}
if !userExists(md, u) {
return common.SimpleResponse(404, "I'd also like to be friends with someone who doesn't even exist (???), however that's NOT POSSIBLE.")
}
var (
relExists bool
isMutual bool
)
err := md.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM users_relationships WHERE user1 = ? AND user2 = ?), EXISTS(SELECT 1 FROM users_relationships WHERE user2 = ? AND user1 = ?)", md.ID(), u, md.ID(), u).Scan(&relExists, &isMutual)
if err != nil && err != sql.ErrNoRows {
md.Err(err)
return Err500
}
if !relExists {
_, err := md.DB.Exec("INSERT INTO users_relationships(user1, user2) VALUES (?, ?)", md.User.UserID, u)
if err != nil {
md.Err(err)
return Err500
}
}
var r friendsWithResponse
r.Code = 200
r.Friends = true
r.Mutual = isMutual
return r
}
// userExists makes sure an user exists.
func userExists(md common.MethodData, u int) (r bool) {
err := md.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE id = ? AND "+
md.User.OnlyUserPublic(true)+")", u).Scan(&r)
if err != nil && err != sql.ErrNoRows {
md.Err(err)
}
return
}
// FriendsDelPOST deletes an user's friend.
func FriendsDelPOST(md common.MethodData) common.CodeMessager {
var u struct {
User int `json:"user"`
}
md.Unmarshal(&u)
return delFriend(md, u.User)
}
func delFriend(md common.MethodData, u int) common.CodeMessager {
_, err := md.DB.Exec("DELETE FROM users_relationships WHERE user1 = ? AND user2 = ?", md.ID(), u)
if err != nil {
md.Err(err)
return Err500
}
r := friendsWithResponse{
Friends: false,
Mutual: false,
}
r.Code = 200
return r
}

View File

@@ -0,0 +1,121 @@
package v1
import (
"fmt"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
redis "gopkg.in/redis.v5"
"zxq.co/ripple/ocl"
"zxq.co/ripple/rippleapi/common"
)
type leaderboardUser struct {
userData
ChosenMode modeData `json:"chosen_mode"`
PlayStyle int `json:"play_style"`
FavouriteMode int `json:"favourite_mode"`
}
type leaderboardResponse struct {
common.ResponseBase
Users []leaderboardUser `json:"users"`
}
const lbUserQuery = `
SELECT
users.id, users.username, users.register_datetime, users.privileges, users.latest_activity,
users_stats.username_aka, users_stats.country,
users_stats.play_style, users_stats.favourite_mode,
users_stats.ranked_score_%[1]s, users_stats.total_score_%[1]s, users_stats.playcount_%[1]s,
users_stats.replays_watched_%[1]s, users_stats.total_hits_%[1]s,
users_stats.avg_accuracy_%[1]s, users_stats.pp_%[1]s
FROM users
INNER JOIN users_stats ON users_stats.id = users.id
WHERE users.id IN (?)
`
// LeaderboardGET gets the leaderboard.
func LeaderboardGET(md common.MethodData) common.CodeMessager {
m := getMode(md.Query("mode"))
// md.Query.Country
p := common.Int(md.Query("p")) - 1
if p < 0 {
p = 0
}
l := common.InString(1, md.Query("l"), 500, 50)
key := "ripple:leaderboard:" + m
if md.Query("country") != "" {
key += ":" + md.Query("country")
}
results, err := md.R.ZRevRange(key, int64(p*l), int64(p*l+l-1)).Result()
if err != nil {
md.Err(err)
return Err500
}
var resp leaderboardResponse
resp.Code = 200
if len(results) == 0 {
return resp
}
query := fmt.Sprintf(lbUserQuery+` ORDER BY users_stats.pp_%[1]s DESC, users_stats.ranked_score_%[1]s DESC`, m)
query, params, _ := sqlx.In(query, results)
rows, err := md.DB.Query(query, params...)
if err != nil {
md.Err(err)
return Err500
}
for rows.Next() {
var u leaderboardUser
err := rows.Scan(
&u.ID, &u.Username, &u.RegisteredOn, &u.Privileges, &u.LatestActivity,
&u.UsernameAKA, &u.Country, &u.PlayStyle, &u.FavouriteMode,
&u.ChosenMode.RankedScore, &u.ChosenMode.TotalScore, &u.ChosenMode.PlayCount,
&u.ChosenMode.ReplaysWatched, &u.ChosenMode.TotalHits,
&u.ChosenMode.Accuracy, &u.ChosenMode.PP,
)
if err != nil {
md.Err(err)
continue
}
u.ChosenMode.Level = ocl.GetLevelPrecise(int64(u.ChosenMode.TotalScore))
if i := leaderboardPosition(md.R, m, u.ID); i != nil {
u.ChosenMode.GlobalLeaderboardRank = i
}
if i := countryPosition(md.R, m, u.ID, u.Country); i != nil {
u.ChosenMode.CountryLeaderboardRank = i
}
resp.Users = append(resp.Users, u)
}
return resp
}
func leaderboardPosition(r *redis.Client, mode string, user int) *int {
return _position(r, "ripple:leaderboard:"+mode, user)
}
func countryPosition(r *redis.Client, mode string, user int, country string) *int {
return _position(r, "ripple:leaderboard:"+mode+":"+strings.ToLower(country), user)
}
func _position(r *redis.Client, key string, user int) *int {
res := r.ZRevRank(key, strconv.Itoa(user))
if res.Err() == redis.Nil {
return nil
}
x := int(res.Val()) + 1
return &x
}

View File

@@ -0,0 +1,192 @@
package v1
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"zxq.co/ripple/rippleapi/common"
)
type setAllowedData struct {
UserID int `json:"user_id"`
Allowed int `json:"allowed"`
}
// UserManageSetAllowedPOST allows to set the allowed status of an user.
func UserManageSetAllowedPOST(md common.MethodData) common.CodeMessager {
var data setAllowedData
if err := md.Unmarshal(&data); err != nil {
return ErrBadJSON
}
if data.Allowed < 0 || data.Allowed > 2 {
return common.SimpleResponse(400, "Allowed status must be between 0 and 2")
}
var banDatetime int64
var privsSet string
if data.Allowed == 0 {
banDatetime = time.Now().Unix()
privsSet = "privileges = (privileges & ~3)"
} else {
banDatetime = 0
privsSet = "privileges = (privileges | 3)"
}
_, err := md.DB.Exec("UPDATE users SET "+privsSet+", ban_datetime = ? WHERE id = ?", banDatetime, data.UserID)
if err != nil {
md.Err(err)
return Err500
}
rapLog(md, fmt.Sprintf("changed UserID:%d's allowed to %d. This was done using the API's terrible ManageSetAllowed.", data.UserID, data.Allowed))
go fixPrivileges(data.UserID, md.DB)
query := `
SELECT users.id, users.username, register_datetime, privileges,
latest_activity, users_stats.username_aka,
users_stats.country
FROM users
LEFT JOIN users_stats
ON users.id=users_stats.id
WHERE users.id=?
LIMIT 1`
return userPutsSingle(md, md.DB.QueryRowx(query, data.UserID))
}
type userEditData struct {
ID int `json:"id"`
Username *string `json:"username"`
UsernameAKA *string `json:"username_aka"`
//Privileges *uint64 `json:"privileges"`
Country *string `json:"country"`
SilenceInfo *silenceInfo `json:"silence_info"`
ResetUserpage bool `json:"reset_userpage"`
//ResetAvatar bool `json:"reset_avatar"`
}
// UserEditPOST allows to edit an user's information.
func UserEditPOST(md common.MethodData) common.CodeMessager {
var data userEditData
if err := md.Unmarshal(&data); err != nil {
fmt.Println(err)
return ErrBadJSON
}
if data.ID == 0 {
return common.SimpleResponse(404, "That user could not be found")
}
var prevUser struct {
Username string
Privileges uint64
}
err := md.DB.Get(&prevUser, "SELECT username, privileges FROM users WHERE id = ? LIMIT 1", data.ID)
switch err {
case nil: // carry on
case sql.ErrNoRows:
return common.SimpleResponse(404, "That user could not be found")
default:
md.Err(err)
return Err500
}
const initQuery = "UPDATE users SET\n"
q := initQuery
var args []interface{}
// totally did not realise I had to update some fields in users_stats as well
// and just copy pasting the above code by prefixing "stats" to every
// variable
const statsInitQuery = "UPDATE users_stats SET\n"
statsQ := statsInitQuery
var statsArgs []interface{}
if common.UserPrivileges(prevUser.Privileges)&common.AdminPrivilegeManageUsers != 0 &&
data.ID != md.User.UserID {
return common.SimpleResponse(403, "Can't edit that user")
}
if data.Username != nil {
if strings.Contains(*data.Username, " ") && strings.Contains(*data.Username, "_") {
return common.SimpleResponse(400, "Mixed spaces and underscores")
}
if usernameAvailable(md, *data.Username, data.ID) {
return common.SimpleResponse(409, "User with that username exists")
}
jsonData, _ := json.Marshal(struct {
UserID int `json:"userID"`
NewUsername string `json:"newUsername"`
}{data.ID, *data.Username})
md.R.Publish("peppy:change_username", string(jsonData))
appendToUserNotes(md, "Username change: "+prevUser.Username+" -> "+*data.Username, data.ID)
}
if data.UsernameAKA != nil {
statsQ += "username_aka = ?,\n"
statsArgs = append(statsArgs, *data.UsernameAKA)
}
/*if data.Privileges != nil {
q += "privileges = ?,\n"
args = append(args, *data.Privileges)
// UserNormal or UserPublic changed
if *data.Privileges & 3 != 3 && *data.Privileges & 3 != prevUser.Privileges & 3 {
q += "ban_datetime = ?"
args = append(args, meme)
}
// https://zxq.co/ripple/old-frontend/src/master/inc/Do.php#L355 ?
// should also check for AdminManagePrivileges
// should also check out the code for CM restring/banning
}*/
if data.Country != nil {
statsQ += "country = ?,\n"
statsArgs = append(statsArgs, *data.Country)
rapLog(md, fmt.Sprintf("has changed %s country to %s", prevUser.Username, *data.Country))
appendToUserNotes(md, "country changed to "+*data.Country, data.ID)
}
if data.SilenceInfo != nil && md.User.UserPrivileges&common.AdminPrivilegeSilenceUsers != 0 {
q += "silence_end = ?, silence_reason = ?,\n"
args = append(args, time.Time(data.SilenceInfo.End).Unix(), data.SilenceInfo.Reason)
}
if data.ResetUserpage {
statsQ += "userpage_content = '',\n"
}
if q != initQuery {
q = q[:len(q)-2] + " WHERE id = ? LIMIT 1"
args = append(args, data.ID)
_, err = md.DB.Exec(q, args...)
if err != nil {
md.Err(err)
return Err500
}
}
if statsQ != statsInitQuery {
statsQ = statsQ[:len(statsQ)-2] + " WHERE id = ? LIMIT 1"
statsArgs = append(statsArgs, data.ID)
_, err = md.DB.Exec(statsQ, statsArgs...)
if err != nil {
md.Err(err)
return Err500
}
}
rapLog(md, fmt.Sprintf("has updated user %s", prevUser.Username))
return userPutsSingle(md, md.DB.QueryRowx(userFields+" WHERE users.id = ? LIMIT 1", data.ID))
}
func appendToUserNotes(md common.MethodData, message string, user int) {
message = "\n[" + time.Now().Format("2006-01-02 15:04:05") + "] API: " + message
_, err := md.DB.Exec("UPDATE users SET notes = CONCAT(COALESCE(notes, ''), ?) WHERE id = ?",
message, user)
if err != nil {
md.Err(err)
}
}
func usernameAvailable(md common.MethodData, u string, userID int) (r bool) {
err := md.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE username_safe = ? AND id != ?)", common.SafeUsername(u), userID).Scan(&r)
if err != nil && err != sql.ErrNoRows {
md.Err(err)
}
return
}

View File

@@ -0,0 +1,108 @@
// +build !windows
// TODO: Make all these methods POST
package v1
import (
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"syscall"
"time"
"zxq.co/ripple/rippleapi/common"
)
// MetaRestartGET restarts the API with Zero Downtime™.
func MetaRestartGET(md common.MethodData) common.CodeMessager {
proc, err := os.FindProcess(syscall.Getpid())
if err != nil {
return common.SimpleResponse(500, "couldn't find process. what the fuck?")
}
go func() {
time.Sleep(time.Second)
proc.Signal(syscall.SIGUSR2)
}()
return common.SimpleResponse(200, "brb")
}
var upSince = time.Now()
type metaUpSinceResponse struct {
common.ResponseBase
Code int `json:"code"`
Since int64 `json:"since"`
}
// MetaUpSinceGET retrieves the moment the API application was started.
// Mainly used to get if the API was restarted.
func MetaUpSinceGET(md common.MethodData) common.CodeMessager {
return metaUpSinceResponse{
Code: 200,
Since: int64(upSince.UnixNano()),
}
}
// MetaUpdateGET updates the API to the latest version, and restarts it.
func MetaUpdateGET(md common.MethodData) common.CodeMessager {
if f, err := os.Stat(".git"); err == os.ErrNotExist || !f.IsDir() {
return common.SimpleResponse(500, "instance is not using git")
}
go func() {
if !execCommand("git", "pull", "origin", "master") {
return
}
// go get
// -u: update all dependencies
// -d: stop after downloading deps
if !execCommand("go", "get", "-v", "-u", "-d") {
return
}
if !execCommand("bash", "-c", "go build -v -ldflags \"-X main.Version=`git rev-parse HEAD`\"") {
return
}
proc, err := os.FindProcess(syscall.Getpid())
if err != nil {
log.Println(err)
return
}
proc.Signal(syscall.SIGUSR2)
}()
return common.SimpleResponse(200, "Started updating! "+surpriseMe())
}
func execCommand(command string, args ...string) bool {
cmd := exec.Command(command, args...)
cmd.Env = os.Environ()
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Println(err)
return false
}
stderr, err := cmd.StderrPipe()
if err != nil {
log.Println(err)
return false
}
if err := cmd.Start(); err != nil {
log.Println(err)
return false
}
data, err := ioutil.ReadAll(stderr)
if err != nil {
log.Println(err)
return false
}
// Bob. We got a problem.
if len(data) != 0 {
log.Println(string(data))
}
io.Copy(os.Stdout, stdout)
cmd.Wait()
stdout.Close()
return true
}

View File

@@ -0,0 +1,42 @@
// +build windows
package v1
import (
"time"
"zxq.co/ripple/rippleapi/common"
)
// MetaRestartGET restarts the API with Zero Downtime™.
func MetaRestartGET(md common.MethodData) common.CodeMessager {
return common.SimpleResponse(200, "brb in your dreams")
}
// MetaKillGET kills the API process. NOTE TO EVERYONE: NEVER. EVER. USE IN PROD.
// Mainly created because I couldn't bother to fire up a terminal, do htop and kill the API each time.
func MetaKillGET(md common.MethodData) common.CodeMessager {
return common.SimpleResponse(200, "haha")
}
var upSince = time.Now()
type metaUpSinceResponse struct {
common.ResponseBase
Code int `json:"code"`
Since int64 `json:"since"`
}
// MetaUpSinceGET retrieves the moment the API application was started.
// Mainly used to get if the API was restarted.
func MetaUpSinceGET(md common.MethodData) common.CodeMessager {
return metaUpSinceResponse{
Code: 200,
Since: int64(upSince.UnixNano()),
}
}
// MetaUpdateGET updates the API to the latest version, and restarts it.
func MetaUpdateGET(md common.MethodData) common.CodeMessager {
return common.SimpleResponse(200, "lol u wish")
}

View File

@@ -0,0 +1,139 @@
package v1
import (
"math/rand"
"time"
"zxq.co/ripple/rippleapi/common"
)
var rn = rand.New(rand.NewSource(time.Now().UnixNano()))
var kaomojis = [...]string{
"Σ(ノ°▽°)",
"( ƅ°ਉ°)ƅ",
"ヽ( ・∀・)ノ",
"˭̡̞(◞⁎˃ᆺ˂)◞*✰",
"(p^-^)p",
"(ノ^∇^)ノ゚",
"ヽ(〃・ω・)ノ",
"(۶* ‘ꆚ’)۶”",
"(。>ω<)。",
"(ノ。≧◇≦)ノ",
"ヾ(。・ω・)シ",
"(ノ・д・)ノ",
".+:。(ノ・ω・)ノ゙",
"Σ(*ノ´>ω<。`)ノ",
"ヾ(〃^∇^)ノ♪",
"\(@ ̄∇ ̄@)/",
"(^▽^)",
"ヾ(@^▽^@)",
"(((v)))",
"(*T▽T*)",
"\(^▽^)/",
"\(T∇T)/",
"ヽ( ★ω★)",
"ヽ(;▽;)",
"ヾ(。◕ฺ∀◕ฺ)",
"ヾ(@† ▽ †@)ノ",
"ヾ(^∇^)",
"ヾ(^▽^)ノ",
"ヾ(@^▽^@)ノ",
"ヾ(@゜▽゜@)ノ",
"(.=^・ェ・^=)",
"((≡^⚲͜^≡))",
"(^・o・^)ノ”",
"(^._.^)ノ",
"(^人^)",
"(=;ェ;=)",
"(=`ω´=)",
"(=`ェ´=)",
"=´∇`=",
"(=^・^=)",
"(=^・ェ・^=)",
"(=^‥^=)",
"(=TェT=)",
"(=xェx=)",
"(=^‥^)/`",
"~(=^‥^)/",
"└(=^‥^=)┐",
"ヾ(=゚・゚=)ノ",
"ヽ(=^・ω・^=)丿",
"d(=^・ω・^=)b",
"o(^・x・^)o",
"V(=^・ω・^=)v",
"(⁎˃ᆺ˂)",
"(,,^・⋏・^,,)",
}
var randomSentences = [...]string{
"Proudly sponsored by Kirotuso!",
"The brace is on fire!",
"deverupa ga daisuki!",
"It works!!!!",
"Feelin' groovy!",
"sudo rm -rf /",
"Hi! I'm Flowey! Flowey the flower!",
"Ripple devs are actually cats",
"Support Howl's fund for buying a power supply for his SSD!",
"Superman dies",
"PP when?",
"RWC hype",
"I'd just like to interject for a moment.",
"Running on an apple pie!",
":thinking:",
"The total entropy of an isolated system can only increase over time",
"Where are my testicles, Summer?",
"Why don't you ask the smartest people in the universe? Oh yeah, you can't. They blew up.",
}
func surpriseMe() string {
n := int(time.Now().UnixNano())
return randomSentences[n%len(randomSentences)] + " " + kaomojis[n%len(kaomojis)]
}
type pingResponse struct {
common.ResponseBase
ID int `json:"user_id"`
Privileges common.Privileges `json:"privileges"`
UserPrivileges common.UserPrivileges `json:"user_privileges"`
PrivilegesS string `json:"privileges_string"`
UserPrivilegesS string `json:"user_privileges_string"`
}
// PingGET is a message to check with the API that we are logged in, and know what are our privileges.
func PingGET(md common.MethodData) common.CodeMessager {
var r pingResponse
r.Code = 200
if md.ID() == 0 {
r.Message = "You have not given us a token, so we don't know who you are! But you can still login with POST /tokens " + kaomojis[rn.Intn(len(kaomojis))]
} else {
r.Message = surpriseMe()
}
r.ID = md.ID()
r.Privileges = md.User.TokenPrivileges
r.UserPrivileges = md.User.UserPrivileges
r.PrivilegesS = md.User.TokenPrivileges.String()
r.UserPrivilegesS = md.User.UserPrivileges.String()
return r
}
type surpriseMeResponse struct {
common.ResponseBase
Cats [100]string `json:"cats"`
}
// SurpriseMeGET generates cute cats.
//
// ... Yes.
func SurpriseMeGET(md common.MethodData) common.CodeMessager {
var r surpriseMeResponse
r.Code = 200
for i := 0; i < 100; i++ {
r.Cats[i] = surpriseMe()
}
return r
}

View File

@@ -0,0 +1,87 @@
package v1
import (
"fmt"
"time"
"zxq.co/ripple/rippleapi/common"
)
type rapLogData struct {
Through string `json:"through"`
Text string `json:"text"`
}
type rapLogMessage struct {
rapLogData
Author int `json:"author"`
CreatedAt time.Time `json:"created_at"`
}
type rapLogResponse struct {
common.ResponseBase
rapLogMessage
}
// RAPLogPOST creates a new entry in the RAP logs
func RAPLogPOST(md common.MethodData) common.CodeMessager {
if md.User.UserPrivileges&common.AdminPrivilegeAccessRAP == 0 {
return common.SimpleResponse(403, "Got lost, kiddo?")
}
var d rapLogData
if err := md.Unmarshal(&d); err != nil {
fmt.Println(err)
return ErrBadJSON
}
if d.Text == "" {
return ErrMissingField("text")
}
if d.Through == "" {
ua := string(md.Ctx.UserAgent())
if len(ua) > 20 {
ua = ua[:20] + "…"
}
d.Through = "API"
if ua != "" {
d.Through += " (" + ua + ")"
}
}
if len(d.Through) > 30 {
d.Through = d.Through[:30]
}
created := time.Now()
_, err := md.DB.Exec("INSERT INTO rap_logs(userid, text, datetime, through) VALUES (?, ?, ?, ?)",
md.User.UserID, d.Text, created.Unix(), d.Through)
if err != nil {
md.Err(err)
return Err500
}
var resp rapLogResponse
resp.rapLogData = d
resp.Author = md.User.UserID
resp.CreatedAt = created.Truncate(time.Second)
resp.Code = 200
return resp
}
func rapLog(md common.MethodData, message string) {
ua := string(md.Ctx.UserAgent())
if len(ua) > 20 {
ua = ua[:20] + "…"
}
through := "API"
if ua != "" {
through += " (" + ua + ")"
}
_, err := md.DB.Exec("INSERT INTO rap_logs(userid, text, datetime, through) VALUES (?, ?, ?, ?)",
md.User.UserID, message, time.Now().Unix(), through)
if err != nil {
md.Err(err)
}
}

View File

@@ -0,0 +1,156 @@
package v1
import (
"database/sql"
"fmt"
"strconv"
"strings"
"gopkg.in/thehowl/go-osuapi.v1"
"zxq.co/ripple/rippleapi/common"
"zxq.co/x/getrank"
)
// Score is a score done on Ripple.
type Score struct {
ID int `json:"id"`
BeatmapMD5 string `json:"beatmap_md5"`
Score int64 `json:"score"`
MaxCombo int `json:"max_combo"`
FullCombo bool `json:"full_combo"`
Mods int `json:"mods"`
Count300 int `json:"count_300"`
Count100 int `json:"count_100"`
Count50 int `json:"count_50"`
CountGeki int `json:"count_geki"`
CountKatu int `json:"count_katu"`
CountMiss int `json:"count_miss"`
Time common.UnixTimestamp `json:"time"`
PlayMode int `json:"play_mode"`
Accuracy float64 `json:"accuracy"`
PP float32 `json:"pp"`
Rank string `json:"rank"`
Completed int `json:"completed"`
}
// beatmapScore is to differentiate from userScore, as beatmapScore contains
// also an user, while userScore contains the beatmap.
type beatmapScore struct {
Score
User userData `json:"user"`
}
type scoresResponse struct {
common.ResponseBase
Scores []beatmapScore `json:"scores"`
}
// ScoresGET retrieves the top scores for a certain beatmap.
func ScoresGET(md common.MethodData) common.CodeMessager {
var (
beatmapMD5 string
r scoresResponse
)
switch {
case md.Query("md5") != "":
beatmapMD5 = md.Query("md5")
case md.Query("b") != "":
err := md.DB.Get(&beatmapMD5, "SELECT beatmap_md5 FROM beatmaps WHERE beatmap_id = ? LIMIT 1", md.Query("b"))
switch {
case err == sql.ErrNoRows:
r.Code = 200
return r
case err != nil:
md.Err(err)
return Err500
}
default:
return ErrMissingField("md5|b")
}
sort := common.Sort(md, common.SortConfiguration{
Default: "scores.pp DESC, scores.score DESC",
Table: "scores",
Allowed: []string{"pp", "score", "accuracy", "id"},
})
rows, err := md.DB.Query(`
SELECT
scores.id, scores.beatmap_md5, scores.score,
scores.max_combo, scores.full_combo, scores.mods,
scores.300_count, scores.100_count, scores.50_count,
scores.gekis_count, scores.katus_count, scores.misses_count,
scores.time, scores.play_mode, scores.accuracy, scores.pp,
scores.completed,
users.id, users.username, users.register_datetime, users.privileges,
users.latest_activity, users_stats.username_aka, users_stats.country
FROM scores
INNER JOIN users ON users.id = scores.userid
INNER JOIN users_stats ON users_stats.id = scores.userid
WHERE scores.beatmap_md5 = ? AND scores.completed = '3' AND `+md.User.OnlyUserPublic(false)+
` `+genModeClause(md)+`
`+sort+common.Paginate(md.Query("p"), md.Query("l"), 100), beatmapMD5)
if err != nil {
md.Err(err)
return Err500
}
for rows.Next() {
var (
s beatmapScore
u userData
)
err := rows.Scan(
&s.ID, &s.BeatmapMD5, &s.Score.Score,
&s.MaxCombo, &s.FullCombo, &s.Mods,
&s.Count300, &s.Count100, &s.Count50,
&s.CountGeki, &s.CountKatu, &s.CountMiss,
&s.Time, &s.PlayMode, &s.Accuracy, &s.PP,
&s.Completed,
&u.ID, &u.Username, &u.RegisteredOn, &u.Privileges,
&u.LatestActivity, &u.UsernameAKA, &u.Country,
)
if err != nil {
md.Err(err)
continue
}
s.User = u
s.Rank = strings.ToUpper(getrank.GetRank(
osuapi.Mode(s.PlayMode),
osuapi.Mods(s.Mods),
s.Accuracy,
s.Count300,
s.Count100,
s.Count50,
s.CountMiss,
))
r.Scores = append(r.Scores, s)
}
r.Code = 200
return r
}
func getMode(m string) string {
switch m {
case "1":
return "taiko"
case "2":
return "ctb"
case "3":
return "mania"
default:
return "std"
}
}
func genModeClause(md common.MethodData) string {
var modeClause string
if md.Query("mode") != "" {
m, err := strconv.Atoi(md.Query("mode"))
if err == nil && m >= 0 && m <= 3 {
modeClause = fmt.Sprintf("AND scores.play_mode = '%d'", m)
}
}
return modeClause
}

View File

@@ -0,0 +1,167 @@
package v1
import (
"strings"
"zxq.co/ripple/rippleapi/common"
"zxq.co/ripple/semantic-icons-ugc"
)
type donorInfoResponse struct {
common.ResponseBase
HasDonor bool `json:"has_donor"`
Expiration common.UnixTimestamp `json:"expiration"`
}
// UsersSelfDonorInfoGET returns information about the users' donor status
func UsersSelfDonorInfoGET(md common.MethodData) common.CodeMessager {
var r donorInfoResponse
var privileges uint64
err := md.DB.QueryRow("SELECT privileges, donor_expire FROM users WHERE id = ?", md.ID()).
Scan(&privileges, &r.Expiration)
if err != nil {
md.Err(err)
return Err500
}
r.HasDonor = common.UserPrivileges(privileges)&common.UserPrivilegeDonor > 0
r.Code = 200
return r
}
type favouriteModeResponse struct {
common.ResponseBase
FavouriteMode int `json:"favourite_mode"`
}
// UsersSelfFavouriteModeGET gets the current user's favourite mode
func UsersSelfFavouriteModeGET(md common.MethodData) common.CodeMessager {
var f favouriteModeResponse
f.Code = 200
if md.ID() == 0 {
return f
}
err := md.DB.QueryRow("SELECT users_stats.favourite_mode FROM users_stats WHERE id = ?", md.ID()).
Scan(&f.FavouriteMode)
if err != nil {
md.Err(err)
return Err500
}
return f
}
type userSettingsData struct {
UsernameAKA *string `json:"username_aka"`
FavouriteMode *int `json:"favourite_mode"`
CustomBadge struct {
singleBadge
Show *bool `json:"show"`
} `json:"custom_badge"`
PlayStyle *int `json:"play_style"`
}
// UsersSelfSettingsPOST allows to modify information about the current user.
func UsersSelfSettingsPOST(md common.MethodData) common.CodeMessager {
var d userSettingsData
md.Unmarshal(&d)
// input sanitisation
*d.UsernameAKA = common.SanitiseString(*d.UsernameAKA)
if md.User.UserPrivileges&common.UserPrivilegeDonor > 0 {
d.CustomBadge.Name = common.SanitiseString(d.CustomBadge.Name)
d.CustomBadge.Icon = sanitiseIconName(d.CustomBadge.Icon)
} else {
d.CustomBadge.singleBadge = singleBadge{}
d.CustomBadge.Show = nil
}
d.FavouriteMode = intPtrIn(0, d.FavouriteMode, 3)
q := new(common.UpdateQuery).
Add("s.username_aka", d.UsernameAKA).
Add("s.favourite_mode", d.FavouriteMode).
Add("s.custom_badge_name", d.CustomBadge.Name).
Add("s.custom_badge_icon", d.CustomBadge.Icon).
Add("s.show_custom_badge", d.CustomBadge.Show).
Add("s.play_style", d.PlayStyle)
_, err := md.DB.Exec("UPDATE users u, users_stats s SET "+q.Fields()+" WHERE s.id = u.id AND u.id = ?", append(q.Parameters, md.ID())...)
if err != nil {
md.Err(err)
return Err500
}
return UsersSelfSettingsGET(md)
}
func sanitiseIconName(s string) string {
classes := strings.Split(s, " ")
n := make([]string, 0, len(classes))
for _, c := range classes {
if !in(c, n) && in(c, semanticiconsugc.SaneIcons) {
n = append(n, c)
}
}
return strings.Join(n, " ")
}
func in(a string, b []string) bool {
for _, x := range b {
if x == a {
return true
}
}
return false
}
type userSettingsResponse struct {
common.ResponseBase
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Flags uint `json:"flags"`
userSettingsData
}
// UsersSelfSettingsGET allows to get "sensitive" information about the current user.
func UsersSelfSettingsGET(md common.MethodData) common.CodeMessager {
var r userSettingsResponse
var ccb bool
r.Code = 200
err := md.DB.QueryRow(`
SELECT
u.id, u.username,
u.email, s.username_aka, s.favourite_mode,
s.show_custom_badge, s.custom_badge_icon,
s.custom_badge_name, s.can_custom_badge,
s.play_style, u.flags
FROM users u
LEFT JOIN users_stats s ON u.id = s.id
WHERE u.id = ?`, md.ID()).Scan(
&r.ID, &r.Username,
&r.Email, &r.UsernameAKA, &r.FavouriteMode,
&r.CustomBadge.Show, &r.CustomBadge.Icon,
&r.CustomBadge.Name, &ccb,
&r.PlayStyle, &r.Flags,
)
if err != nil {
md.Err(err)
return Err500
}
if !ccb {
r.CustomBadge = struct {
singleBadge
Show *bool `json:"show"`
}{}
}
return r
}
func intPtrIn(x int, y *int, z int) *int {
if y == nil {
return nil
}
if *y > z {
return nil
}
if *y < x {
return nil
}
return y
}

View File

@@ -0,0 +1,219 @@
package v1
import (
"crypto/md5"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"github.com/jmoiron/sqlx"
"zxq.co/ripple/rippleapi/common"
"zxq.co/ripple/schiavolib"
)
// TokenSelfDeletePOST deletes the token the user is connecting with.
func TokenSelfDeletePOST(md common.MethodData) common.CodeMessager {
if md.ID() == 0 {
return common.SimpleResponse(400, "How should we delete your token if you haven't even given us one?!")
}
var err error
if md.IsBearer() {
_, err = md.DB.Exec("DELETE FROM osin_access WHERE access_token = ? LIMIT 1",
fmt.Sprintf("%x", sha256.Sum256([]byte(md.User.Value))))
} else {
_, err = md.DB.Exec("DELETE FROM tokens WHERE token = ? LIMIT 1",
fmt.Sprintf("%x", md5.Sum([]byte(md.User.Value))))
}
if err != nil {
md.Err(err)
return Err500
}
return common.SimpleResponse(200, "Bye!")
}
type token struct {
ID int `json:"id"`
Privileges uint64 `json:"privileges"`
Description string `json:"description"`
LastUpdated common.UnixTimestamp `json:"last_updated"`
}
type tokenResponse struct {
common.ResponseBase
Tokens []token `json:"tokens"`
}
// TokenGET retrieves a list listing all the user's public tokens.
func TokenGET(md common.MethodData) common.CodeMessager {
wc := common.Where("user = ? AND private = 0", strconv.Itoa(md.ID()))
if md.Query("id") != "" {
wc.Where("id = ?", md.Query("id"))
}
rows, err := md.DB.Query("SELECT id, privileges, description, last_updated FROM tokens "+
wc.Clause+common.Paginate(md.Query("p"), md.Query("l"), 50), wc.Params...)
if err != nil {
return Err500
}
var r tokenResponse
for rows.Next() {
var t token
err = rows.Scan(&t.ID, &t.Privileges, &t.Description, &t.LastUpdated)
if err != nil {
md.Err(err)
continue
}
r.Tokens = append(r.Tokens, t)
}
r.Code = 200
return r
}
type oauthClient struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID int `json:"owner_id"`
Avatar string `json:"avatar"`
}
// Scan scans the extra in the mysql table into Name, OwnerID and Avatar.
func (o *oauthClient) Scan(src interface{}) error {
var s []byte
switch x := src.(type) {
case string:
s = []byte(x)
case []byte:
s = x
default:
return errors.New("Can't scan non-string")
}
var vals [3]string
err := json.Unmarshal(s, &vals)
if err != nil {
return err
}
o.Name = vals[0]
o.OwnerID, _ = strconv.Atoi(vals[1])
o.Avatar = vals[2]
return nil
}
type bearerToken struct {
Client oauthClient `json:"client"`
Scope string `json:"scope"`
Privileges common.Privileges `json:"privileges"`
Created time.Time `json:"created"`
}
type tokenSingleResponse struct {
common.ResponseBase
token
}
type bearerTokenSingleResponse struct {
common.ResponseBase
bearerToken
}
// TokenSelfGET retrieves information about the token the user is connecting with.
func TokenSelfGET(md common.MethodData) common.CodeMessager {
if md.ID() == 0 {
return common.SimpleResponse(404, "How are we supposed to find the token you're using if you ain't even using one?!")
}
if md.IsBearer() {
return getBearerToken(md)
}
var r tokenSingleResponse
// md.User.ID = token id, userid would have been md.User.UserID. what a clusterfuck
err := md.DB.QueryRow("SELECT id, privileges, description, last_updated FROM tokens WHERE id = ?", md.User.ID).Scan(
&r.ID, &r.Privileges, &r.Description, &r.LastUpdated,
)
if err != nil {
md.Err(err)
return Err500
}
r.Code = 200
return r
}
func getBearerToken(md common.MethodData) common.CodeMessager {
var b bearerTokenSingleResponse
err := md.DB.
QueryRow(`
SELECT t.scope, t.created_at, c.id, c.extra
FROM osin_access t INNER JOIN osin_client c
WHERE t.access_token = ?
`, fmt.Sprintf("%x", sha256.Sum256([]byte(md.User.Value)))).Scan(
&b.Scope, &b.Created, &b.Client.ID, &b.Client,
)
if err != nil {
md.Err(err)
return Err500
}
b.Code = 200
b.Privileges = md.User.TokenPrivileges
return b
}
// TokenFixPrivilegesPOST fixes the privileges on the token of the given user,
// or of all the users if no user is given.
func TokenFixPrivilegesPOST(md common.MethodData) common.CodeMessager {
id := common.Int(md.Query("id"))
if md.Query("id") == "self" {
id = md.ID()
}
go fixPrivileges(id, md.DB)
return common.SimpleResponse(200, "Privilege fixing started!")
}
func fixPrivileges(user int, db *sqlx.DB) {
var wc string
var params = make([]interface{}, 0, 1)
if user != 0 {
// dirty, but who gives a shit
wc = "WHERE user = ?"
params = append(params, user)
}
rows, err := db.Query(`
SELECT
tokens.id, tokens.privileges, users.privileges
FROM tokens
LEFT JOIN users ON users.id = tokens.user
`+wc, params...)
if err != nil {
fmt.Println(err)
schiavo.Bunker.Send(err.Error())
return
}
for rows.Next() {
var (
id int
privsRaw uint64
privs common.Privileges
newPrivs common.Privileges
privilegesRaw uint64
)
err := rows.Scan(&id, &privsRaw, &privilegesRaw)
if err != nil {
fmt.Println(err)
continue
}
privileges := common.UserPrivileges(privilegesRaw)
privs = common.Privileges(privsRaw)
newPrivs = privs.CanOnly(privileges)
if newPrivs != privs {
_, err := db.Exec("UPDATE tokens SET privileges = ? WHERE id = ? LIMIT 1", uint64(newPrivs), id)
if err != nil {
fmt.Println(err)
schiavo.Bunker.Send(err.Error())
continue
}
}
}
}

View File

@@ -0,0 +1,447 @@
// Package v1 implements the first version of the Ripple API.
package v1
import (
"database/sql"
"strconv"
"strings"
"unicode"
"github.com/jmoiron/sqlx"
"zxq.co/ripple/ocl"
"zxq.co/ripple/rippleapi/common"
)
type userData struct {
ID int `json:"id"`
Username string `json:"username"`
UsernameAKA string `json:"username_aka"`
RegisteredOn common.UnixTimestamp `json:"registered_on"`
Privileges uint64 `json:"privileges"`
LatestActivity common.UnixTimestamp `json:"latest_activity"`
Country string `json:"country"`
}
const userFields = `SELECT users.id, users.username, register_datetime, users.privileges,
latest_activity, users_stats.username_aka,
users_stats.country
FROM users
INNER JOIN users_stats
ON users.id=users_stats.id
`
// UsersGET is the API handler for GET /users
func UsersGET(md common.MethodData) common.CodeMessager {
shouldRet, whereClause, param := whereClauseUser(md, "users")
if shouldRet != nil {
return userPutsMulti(md)
}
query := userFields + `
WHERE ` + whereClause + ` AND ` + md.User.OnlyUserPublic(true) + `
LIMIT 1`
return userPutsSingle(md, md.DB.QueryRowx(query, param))
}
type userPutsSingleUserData struct {
common.ResponseBase
userData
}
func userPutsSingle(md common.MethodData, row *sqlx.Row) common.CodeMessager {
var err error
var user userPutsSingleUserData
err = row.StructScan(&user.userData)
switch {
case err == sql.ErrNoRows:
return common.SimpleResponse(404, "No such user was found!")
case err != nil:
md.Err(err)
return Err500
}
user.Code = 200
return user
}
type userPutsMultiUserData struct {
common.ResponseBase
Users []userData `json:"users"`
}
func userPutsMulti(md common.MethodData) common.CodeMessager {
pm := md.Ctx.Request.URI().QueryArgs().PeekMulti
// query composition
wh := common.
Where("users.username_safe = ?", common.SafeUsername(md.Query("nname"))).
Where("users.id = ?", md.Query("iid")).
Where("users.privileges = ?", md.Query("privileges")).
Where("users.privileges & ? > 0", md.Query("has_privileges")).
Where("users.privileges & ? = 0", md.Query("has_not_privileges")).
Where("users_stats.country = ?", md.Query("country")).
Where("users_stats.username_aka = ?", md.Query("name_aka")).
Where("privileges_groups.name = ?", md.Query("privilege_group")).
In("users.id", pm("ids")...).
In("users.username_safe", safeUsernameBulk(pm("names"))...).
In("users_stats.username_aka", pm("names_aka")...).
In("users_stats.country", pm("countries")...)
var extraJoin string
if md.Query("privilege_group") != "" {
extraJoin = " LEFT JOIN privileges_groups ON users.privileges & privileges_groups.privileges = privileges_groups.privileges "
}
query := userFields + extraJoin + wh.ClauseSafe() + " AND " + md.User.OnlyUserPublic(true) +
" " + common.Sort(md, common.SortConfiguration{
Allowed: []string{
"id",
"username",
"privileges",
"donor_expire",
"latest_activity",
"silence_end",
},
Default: "id ASC",
Table: "users",
}) +
" " + common.Paginate(md.Query("p"), md.Query("l"), 100)
// query execution
rows, err := md.DB.Queryx(query, wh.Params...)
if err != nil {
md.Err(err)
return Err500
}
var r userPutsMultiUserData
for rows.Next() {
var u userData
err := rows.StructScan(&u)
if err != nil {
md.Err(err)
continue
}
r.Users = append(r.Users, u)
}
r.Code = 200
return r
}
// UserSelfGET is a shortcut for /users/id/self. (/users/self)
func UserSelfGET(md common.MethodData) common.CodeMessager {
md.Ctx.Request.URI().SetQueryString("id=self")
return UsersGET(md)
}
func safeUsernameBulk(us [][]byte) [][]byte {
for _, u := range us {
for idx, v := range u {
if v == ' ' {
u[idx] = '_'
continue
}
u[idx] = byte(unicode.ToLower(rune(v)))
}
}
return us
}
type whatIDResponse struct {
common.ResponseBase
ID int `json:"id"`
}
// UserWhatsTheIDGET is an API request that only returns an user's ID.
func UserWhatsTheIDGET(md common.MethodData) common.CodeMessager {
var (
r whatIDResponse
privileges uint64
)
err := md.DB.QueryRow("SELECT id, privileges FROM users WHERE username_safe = ? LIMIT 1", common.SafeUsername(md.Query("name"))).Scan(&r.ID, &privileges)
if err != nil || ((privileges&uint64(common.UserPrivilegePublic)) == 0 &&
(md.User.UserPrivileges&common.AdminPrivilegeManageUsers == 0)) {
return common.SimpleResponse(404, "That user could not be found!")
}
r.Code = 200
return r
}
var modesToReadable = [...]string{
"std",
"taiko",
"ctb",
"mania",
}
type modeData struct {
RankedScore uint64 `json:"ranked_score"`
TotalScore uint64 `json:"total_score"`
PlayCount int `json:"playcount"`
ReplaysWatched int `json:"replays_watched"`
TotalHits int `json:"total_hits"`
Level float64 `json:"level"`
Accuracy float64 `json:"accuracy"`
PP int `json:"pp"`
GlobalLeaderboardRank *int `json:"global_leaderboard_rank"`
CountryLeaderboardRank *int `json:"country_leaderboard_rank"`
}
type userFullResponse struct {
common.ResponseBase
userData
STD modeData `json:"std"`
Taiko modeData `json:"taiko"`
CTB modeData `json:"ctb"`
Mania modeData `json:"mania"`
PlayStyle int `json:"play_style"`
FavouriteMode int `json:"favourite_mode"`
Badges []singleBadge `json:"badges"`
CustomBadge *singleBadge `json:"custom_badge"`
SilenceInfo silenceInfo `json:"silence_info"`
CMNotes *string `json:"cm_notes,omitempty"`
BanDate *common.UnixTimestamp `json:"ban_date,omitempty"`
Email string `json:"email,omitempty"`
}
type silenceInfo struct {
Reason string `json:"reason"`
End common.UnixTimestamp `json:"end"`
}
// UserFullGET gets all of an user's information, with one exception: their userpage.
func UserFullGET(md common.MethodData) common.CodeMessager {
shouldRet, whereClause, param := whereClauseUser(md, "users")
if shouldRet != nil {
return *shouldRet
}
// Hellest query I've ever done.
query := `
SELECT
users.id, users.username, users.register_datetime, users.privileges, users.latest_activity,
users_stats.username_aka, users_stats.country, users_stats.play_style, users_stats.favourite_mode,
users_stats.custom_badge_icon, users_stats.custom_badge_name, users_stats.can_custom_badge,
users_stats.show_custom_badge,
users_stats.ranked_score_std, users_stats.total_score_std, users_stats.playcount_std,
users_stats.replays_watched_std, users_stats.total_hits_std,
users_stats.avg_accuracy_std, users_stats.pp_std,
users_stats.ranked_score_taiko, users_stats.total_score_taiko, users_stats.playcount_taiko,
users_stats.replays_watched_taiko, users_stats.total_hits_taiko,
users_stats.avg_accuracy_taiko, users_stats.pp_taiko,
users_stats.ranked_score_ctb, users_stats.total_score_ctb, users_stats.playcount_ctb,
users_stats.replays_watched_ctb, users_stats.total_hits_ctb,
users_stats.avg_accuracy_ctb, users_stats.pp_ctb,
users_stats.ranked_score_mania, users_stats.total_score_mania, users_stats.playcount_mania,
users_stats.replays_watched_mania, users_stats.total_hits_mania,
users_stats.avg_accuracy_mania, users_stats.pp_mania,
users.silence_reason, users.silence_end,
users.notes, users.ban_datetime, users.email
FROM users
LEFT JOIN users_stats
ON users.id=users_stats.id
WHERE ` + whereClause + ` AND ` + md.User.OnlyUserPublic(true) + `
LIMIT 1
`
// Fuck.
r := userFullResponse{}
var (
b singleBadge
can bool
show bool
)
err := md.DB.QueryRow(query, param).Scan(
&r.ID, &r.Username, &r.RegisteredOn, &r.Privileges, &r.LatestActivity,
&r.UsernameAKA, &r.Country,
&r.PlayStyle, &r.FavouriteMode,
&b.Icon, &b.Name, &can, &show,
&r.STD.RankedScore, &r.STD.TotalScore, &r.STD.PlayCount,
&r.STD.ReplaysWatched, &r.STD.TotalHits,
&r.STD.Accuracy, &r.STD.PP,
&r.Taiko.RankedScore, &r.Taiko.TotalScore, &r.Taiko.PlayCount,
&r.Taiko.ReplaysWatched, &r.Taiko.TotalHits,
&r.Taiko.Accuracy, &r.Taiko.PP,
&r.CTB.RankedScore, &r.CTB.TotalScore, &r.CTB.PlayCount,
&r.CTB.ReplaysWatched, &r.CTB.TotalHits,
&r.CTB.Accuracy, &r.CTB.PP,
&r.Mania.RankedScore, &r.Mania.TotalScore, &r.Mania.PlayCount,
&r.Mania.ReplaysWatched, &r.Mania.TotalHits,
&r.Mania.Accuracy, &r.Mania.PP,
&r.SilenceInfo.Reason, &r.SilenceInfo.End,
&r.CMNotes, &r.BanDate, &r.Email,
)
switch {
case err == sql.ErrNoRows:
return common.SimpleResponse(404, "That user could not be found!")
case err != nil:
md.Err(err)
return Err500
}
can = can && show && common.UserPrivileges(r.Privileges)&common.UserPrivilegeDonor > 0
if can && (b.Name != "" || b.Icon != "") {
r.CustomBadge = &b
}
for modeID, m := range [...]*modeData{&r.STD, &r.Taiko, &r.CTB, &r.Mania} {
m.Level = ocl.GetLevelPrecise(int64(m.TotalScore))
if i := leaderboardPosition(md.R, modesToReadable[modeID], r.ID); i != nil {
m.GlobalLeaderboardRank = i
}
if i := countryPosition(md.R, modesToReadable[modeID], r.ID, r.Country); i != nil {
m.CountryLeaderboardRank = i
}
}
rows, err := md.DB.Query("SELECT b.id, b.name, b.icon FROM user_badges ub "+
"LEFT JOIN badges b ON ub.badge = b.id WHERE user = ?", r.ID)
if err != nil {
md.Err(err)
}
for rows.Next() {
var badge singleBadge
err := rows.Scan(&badge.ID, &badge.Name, &badge.Icon)
if err != nil {
md.Err(err)
continue
}
r.Badges = append(r.Badges, badge)
}
if md.User.TokenPrivileges&common.PrivilegeManageUser == 0 {
r.CMNotes = nil
r.BanDate = nil
r.Email = ""
}
r.Code = 200
return r
}
type userpageResponse struct {
common.ResponseBase
Userpage *string `json:"userpage"`
}
// UserUserpageGET gets an user's userpage, as in the customisable thing.
func UserUserpageGET(md common.MethodData) common.CodeMessager {
shouldRet, whereClause, param := whereClauseUser(md, "users_stats")
if shouldRet != nil {
return *shouldRet
}
var r userpageResponse
err := md.DB.QueryRow("SELECT userpage_content FROM users_stats WHERE "+whereClause+" LIMIT 1", param).Scan(&r.Userpage)
switch {
case err == sql.ErrNoRows:
return common.SimpleResponse(404, "No such user!")
case err != nil:
md.Err(err)
return Err500
}
if r.Userpage == nil {
r.Userpage = new(string)
}
r.Code = 200
return r
}
// UserSelfUserpagePOST allows to change the current user's userpage.
func UserSelfUserpagePOST(md common.MethodData) common.CodeMessager {
var d struct {
Data *string `json:"data"`
}
md.Unmarshal(&d)
if d.Data == nil {
return ErrMissingField("data")
}
cont := common.SanitiseString(*d.Data)
_, err := md.DB.Exec("UPDATE users_stats SET userpage_content = ? WHERE id = ? LIMIT 1", cont, md.ID())
if err != nil {
md.Err(err)
}
md.Ctx.URI().SetQueryString("id=self")
return UserUserpageGET(md)
}
func whereClauseUser(md common.MethodData, tableName string) (*common.CodeMessager, string, interface{}) {
switch {
case md.Query("id") == "self":
return nil, tableName + ".id = ?", md.ID()
case md.Query("id") != "":
id, err := strconv.Atoi(md.Query("id"))
if err != nil {
a := common.SimpleResponse(400, "please pass a valid user ID")
return &a, "", nil
}
return nil, tableName + ".id = ?", id
case md.Query("name") != "":
return nil, tableName + ".username_safe = ?", common.SafeUsername(md.Query("name"))
}
a := common.SimpleResponse(400, "you need to pass either querystring parameters name or id")
return &a, "", nil
}
type userLookupResponse struct {
common.ResponseBase
Users []lookupUser `json:"users"`
}
type lookupUser struct {
ID int `json:"id"`
Username string `json:"username"`
}
// UserLookupGET does a quick lookup of users beginning with the passed
// querystring value name.
func UserLookupGET(md common.MethodData) common.CodeMessager {
name := common.SafeUsername(md.Query("name"))
name = strings.NewReplacer(
"%", "\\%",
"_", "\\_",
"\\", "\\\\",
).Replace(name)
if name == "" {
return common.SimpleResponse(400, "please provide an username to start searching")
}
name = "%" + name + "%"
var email string
if md.User.TokenPrivileges&common.PrivilegeManageUser != 0 &&
strings.Contains(md.Query("name"), "@") {
email = md.Query("name")
}
rows, err := md.DB.Query("SELECT users.id, users.username FROM users WHERE "+
"(username_safe LIKE ? OR email = ?) AND "+
md.User.OnlyUserPublic(true)+" LIMIT 25", name, email)
if err != nil {
md.Err(err)
return Err500
}
var r userLookupResponse
for rows.Next() {
var l lookupUser
err := rows.Scan(&l.ID, &l.Username)
if err != nil {
continue // can't be bothered to handle properly
}
r.Users = append(r.Users, l)
}
r.Code = 200
return r
}

View File

@@ -0,0 +1,84 @@
package v1
import (
"database/sql"
"fmt"
"time"
"github.com/jmoiron/sqlx"
"zxq.co/ripple/rippleapi/common"
)
// Achievement represents an achievement in the database.
type Achievement struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
}
// LoadAchievementsEvery reloads the achievements in the database every given
// amount of time.
func LoadAchievementsEvery(db *sqlx.DB, d time.Duration) {
for {
achievs = nil
err := db.Select(&achievs,
"SELECT id, name, description, icon FROM achievements ORDER BY id ASC")
if err != nil {
fmt.Println("LoadAchievements error", err)
common.GenericError(err)
}
time.Sleep(d)
}
}
var achievs []Achievement
type userAchievement struct {
Achievement
Achieved bool `json:"achieved"`
}
type userAchievementsResponse struct {
common.ResponseBase
Achievements []userAchievement `json:"achievements"`
}
// UserAchievementsGET handles requests for retrieving the achievements of a
// given user.
func UserAchievementsGET(md common.MethodData) common.CodeMessager {
shouldRet, whereClause, param := whereClauseUser(md, "users")
if shouldRet != nil {
return *shouldRet
}
var ids []int
err := md.DB.Select(&ids, `SELECT ua.achievement_id FROM users_achievements ua
INNER JOIN users ON users.id = ua.user_id
WHERE `+whereClause+` ORDER BY ua.achievement_id ASC`, param)
switch {
case err == sql.ErrNoRows:
return common.SimpleResponse(404, "No such user!")
case err != nil:
md.Err(err)
return Err500
}
all := md.HasQuery("all")
resp := userAchievementsResponse{Achievements: make([]userAchievement, 0, len(achievs))}
for _, ach := range achievs {
achieved := inInt(ach.ID, ids)
if all || achieved {
resp.Achievements = append(resp.Achievements, userAchievement{ach, achieved})
}
}
resp.Code = 200
return resp
}
func inInt(i int, js []int) bool {
for _, j := range js {
if i == j {
return true
}
}
return false
}

View File

@@ -0,0 +1,127 @@
package v1
import (
"fmt"
"strings"
"gopkg.in/thehowl/go-osuapi.v1"
"zxq.co/ripple/rippleapi/common"
"zxq.co/x/getrank"
)
type userScore struct {
Score
Beatmap beatmap `json:"beatmap"`
}
type userScoresResponse struct {
common.ResponseBase
Scores []userScore `json:"scores"`
}
const userScoreSelectBase = `
SELECT
scores.id, scores.beatmap_md5, scores.score,
scores.max_combo, scores.full_combo, scores.mods,
scores.300_count, scores.100_count, scores.50_count,
scores.gekis_count, scores.katus_count, scores.misses_count,
scores.time, scores.play_mode, scores.accuracy, scores.pp,
scores.completed,
beatmaps.beatmap_id, beatmaps.beatmapset_id, beatmaps.beatmap_md5,
beatmaps.song_name, beatmaps.ar, beatmaps.od, beatmaps.difficulty_std,
beatmaps.difficulty_taiko, beatmaps.difficulty_ctb, beatmaps.difficulty_mania,
beatmaps.max_combo, beatmaps.hit_length, beatmaps.ranked,
beatmaps.ranked_status_freezed, beatmaps.latest_update
FROM scores
INNER JOIN beatmaps ON beatmaps.beatmap_md5 = scores.beatmap_md5
INNER JOIN users ON users.id = scores.userid
`
// UserScoresBestGET retrieves the best scores of an user, sorted by PP if
// mode is standard and sorted by ranked score otherwise.
func UserScoresBestGET(md common.MethodData) common.CodeMessager {
cm, wc, param := whereClauseUser(md, "users")
if cm != nil {
return *cm
}
mc := genModeClause(md)
// For all modes that have PP, we leave out 0 PP scores.
if getMode(md.Query("mode")) != "ctb" {
mc += " AND scores.pp > 0"
}
return scoresPuts(md, fmt.Sprintf(
`WHERE
scores.completed = '3'
AND %s
%s
AND `+md.User.OnlyUserPublic(true)+`
ORDER BY scores.pp DESC, scores.score DESC %s`,
wc, mc, common.Paginate(md.Query("p"), md.Query("l"), 100),
), param)
}
// UserScoresRecentGET retrieves an user's latest scores.
func UserScoresRecentGET(md common.MethodData) common.CodeMessager {
cm, wc, param := whereClauseUser(md, "users")
if cm != nil {
return *cm
}
return scoresPuts(md, fmt.Sprintf(
`WHERE
%s
%s
AND `+md.User.OnlyUserPublic(true)+`
ORDER BY scores.id DESC %s`,
wc, genModeClause(md), common.Paginate(md.Query("p"), md.Query("l"), 100),
), param)
}
func scoresPuts(md common.MethodData, whereClause string, params ...interface{}) common.CodeMessager {
rows, err := md.DB.Query(userScoreSelectBase+whereClause, params...)
if err != nil {
md.Err(err)
return Err500
}
var scores []userScore
for rows.Next() {
var (
us userScore
b beatmap
)
err = rows.Scan(
&us.ID, &us.BeatmapMD5, &us.Score.Score,
&us.MaxCombo, &us.FullCombo, &us.Mods,
&us.Count300, &us.Count100, &us.Count50,
&us.CountGeki, &us.CountKatu, &us.CountMiss,
&us.Time, &us.PlayMode, &us.Accuracy, &us.PP,
&us.Completed,
&b.BeatmapID, &b.BeatmapsetID, &b.BeatmapMD5,
&b.SongName, &b.AR, &b.OD, &b.Diff2.STD,
&b.Diff2.Taiko, &b.Diff2.CTB, &b.Diff2.Mania,
&b.MaxCombo, &b.HitLength, &b.Ranked,
&b.RankedStatusFrozen, &b.LatestUpdate,
)
if err != nil {
md.Err(err)
return Err500
}
b.Difficulty = b.Diff2.STD
us.Beatmap = b
us.Rank = strings.ToUpper(getrank.GetRank(
osuapi.Mode(us.PlayMode),
osuapi.Mods(us.Mods),
us.Accuracy,
us.Count300,
us.Count100,
us.Count50,
us.CountMiss,
))
scores = append(scores, us)
}
r := userScoresResponse{}
r.Code = 200
r.Scores = scores
return r
}

View File

@@ -0,0 +1,18 @@
package websockets
import (
"github.com/leavengood/websocket"
"github.com/valyala/fasthttp"
)
var upgrader = websocket.FastHTTPUpgrader{
Handler: handler,
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
return true
},
}
// WebsocketV1Entry upgrades a connection to a websocket.
func WebsocketV1Entry(ctx *fasthttp.RequestCtx) {
upgrader.UpgradeHandler(ctx)
}

View File

@@ -0,0 +1,101 @@
package websockets
import (
"crypto/md5"
"crypto/sha256"
"encoding/json"
"fmt"
"database/sql"
"zxq.co/ripple/rippleapi/common"
)
type websocketUser struct {
ID int `json:"id"`
Username string `json:"username"`
UserPrivileges uint64 `json:"user_privileges"`
TokenPrivileges uint64 `json:"token_privileges"`
ApplicationID *string `json:"application_id"`
}
type identifyMessage struct {
Token string `json:"token"`
IsBearer bool `json:"is_bearer"`
}
// Identify sets the identity of the user.
func Identify(c *conn, message incomingMessage) {
var idMsg identifyMessage
err := json.Unmarshal(message.Data, &idMsg)
if err != nil {
c.WriteJSON(TypeInvalidMessage, err.Error())
return
}
var wsu websocketUser
if idMsg.IsBearer {
err = getBearerToken(idMsg.Token, &wsu)
} else {
err = db.Get(&wsu, `
SELECT
t.user as id, t.privileges as token_privileges,
u.username, u.privileges as user_privileges
FROM tokens t
INNER JOIN users u ON t.user = u.id
WHERE t.token = ?`, fmt.Sprintf("%x", md5.Sum([]byte(idMsg.Token))))
}
switch err {
case nil:
break
case sql.ErrNoRows:
c.WriteJSON(TypeNotFound, nil)
return
default:
common.WSErr(err)
c.WriteJSON(TypeUnexpectedError, nil)
return
}
wsu.TokenPrivileges = uint64(
common.Privileges(wsu.TokenPrivileges).CanOnly(
common.UserPrivileges(wsu.UserPrivileges),
),
)
c.Mtx.Lock()
c.User = &wsu
c.Mtx.Unlock()
c.WriteJSON(TypeIdentified, wsu)
}
func getBearerToken(token string, wsu *websocketUser) error {
var x struct {
Client string
Scope string
Extra int
}
err := db.Get(&x, "SELECT client, scope, extra FROM osin_access WHERE access_token = ? LIMIT 1", fmt.Sprintf("%x", sha256.Sum256([]byte(token))))
if err != nil {
return err
}
var userInfo struct {
Username string
Privileges uint64
}
err = db.Get(&userInfo, "SELECT username, privileges FROM users WHERE id = ? LIMIT 1", x.Extra)
if err != nil {
return err
}
wsu.ApplicationID = &x.Client
wsu.ID = x.Extra
wsu.Username = userInfo.Username
wsu.UserPrivileges = userInfo.Privileges
wsu.TokenPrivileges = uint64(common.OAuthPrivileges(x.Scope))
return nil
}

View File

@@ -0,0 +1,125 @@
package websockets
import (
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/leavengood/websocket"
)
var stepNumber uint64
func handler(rawConn *websocket.Conn) {
defer catchPanic()
defer rawConn.Close()
step := atomic.AddUint64(&stepNumber, 1)
// 5 is a security margin in case
if step == (1<<10 - 5) {
atomic.StoreUint64(&stepNumber, 0)
}
c := &conn{
rawConn,
sync.Mutex{},
step | uint64(time.Now().UnixNano()<<10),
false,
nil,
}
c.WriteJSON(TypeConnected, nil)
defer cleanup(c.ID)
for {
var i incomingMessage
err := c.Conn.ReadJSON(&i)
if _, ok := err.(*websocket.CloseError); ok {
return
}
if err != nil {
c.WriteJSON(TypeInvalidMessage, err.Error())
continue
}
f, ok := messageHandler[i.Type]
if !ok {
c.WriteJSON(TypeInvalidMessage, "invalid message type")
continue
}
if f != nil {
f(c, i)
}
}
}
type conn struct {
Conn *websocket.Conn
Mtx sync.Mutex
ID uint64
RestrictedVisible bool
User *websocketUser
}
func (c *conn) WriteJSON(t string, data interface{}) error {
c.Mtx.Lock()
err := c.Conn.WriteJSON(newMessage(t, data))
c.Mtx.Unlock()
return err
}
var messageHandler = map[string]func(c *conn, message incomingMessage){
TypeSubscribeScores: SubscribeScores,
TypeSubscribeMultiMatches: SubscribeMultiMatches,
TypeSetRestrictedVisibility: SetRestrictedVisibility,
TypeIdentify: Identify,
TypePing: pingHandler,
}
// Server Message Types
const (
TypeConnected = "connected"
TypeInvalidMessage = "invalid_message_type"
TypeUnexpectedError = "unexpected_error"
TypeNotFound = "not_found"
TypeSubscribedToScores = "subscribed_to_scores"
TypeNewScore = "new_score"
TypeSubscribedToMultiMatches = "subscribed_mp_complete_match"
TypeNewMatch = "new_completed_match"
TypeIdentified = "identified"
TypeRestrictedVisibilitySet = "restricted_visibility_set"
TypePong = "pong"
)
// Client Message Types
const (
TypeSubscribeScores = "subscribe_scores"
TypeSubscribeMultiMatches = "subscribe_mp_complete_match"
TypeIdentify = "identify"
TypeSetRestrictedVisibility = "set_restricted_visibility"
TypePing = "ping"
)
func pingHandler(c *conn, message incomingMessage) {
c.WriteJSON(TypePong, nil)
}
// Message is the wrapped information for a message sent to the client.
type Message struct {
Type string `json:"type"`
Data interface{} `json:"data,omitempty"`
}
func newMessage(t string, data interface{}) Message {
return Message{
Type: t,
Data: data,
}
}
type incomingMessage struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}

View File

@@ -0,0 +1,57 @@
package websockets
import (
"encoding/json"
"fmt"
"sync"
)
// SubscribeMultiMatches subscribes to receiving information from completed
// games in multiplayer matches.
func SubscribeMultiMatches(c *conn, message incomingMessage) {
multiSubscriptionsMtx.Lock()
var found bool
for _, el := range multiSubscriptions {
if el.ID == c.ID {
found = true
break
}
}
// if it was not found, we need to add it
if !found {
multiSubscriptions = append(multiSubscriptions, c)
}
multiSubscriptionsMtx.Unlock()
c.WriteJSON(TypeSubscribedToMultiMatches, nil)
}
var multiSubscriptions []*conn
var multiSubscriptionsMtx = new(sync.RWMutex)
func matchRetriever() {
ps, err := red.Subscribe("api:mp_complete_match")
if err != nil {
fmt.Println(err)
}
for {
msg, err := ps.ReceiveMessage()
if err != nil {
fmt.Println(err.Error())
return
}
go handleNewMultiGame(msg.Payload)
}
}
func handleNewMultiGame(payload string) {
defer catchPanic()
multiSubscriptionsMtx.RLock()
cp := make([]*conn, len(multiSubscriptions))
copy(cp, multiSubscriptions)
multiSubscriptionsMtx.RUnlock()
for _, el := range cp {
el.WriteJSON(TypeNewMatch, json.RawMessage(payload))
}
}

View File

@@ -0,0 +1,31 @@
package websockets
import (
"encoding/json"
"zxq.co/ripple/rippleapi/common"
)
// SetRestrictedVisibility sets whether the information of restricted users
// can be seen.
func SetRestrictedVisibility(c *conn, message incomingMessage) {
var visibility bool
err := json.Unmarshal(message.Data, &visibility)
if err != nil {
c.WriteJSON(TypeInvalidMessage, err.Error())
return
}
var userIsManager bool
if c.User != nil && (c.User.UserPrivileges&uint64(common.AdminPrivilegeManageUsers) > 0) {
userIsManager = true
}
c.Mtx.Lock()
visibility = visibility && userIsManager
c.RestrictedVisible = visibility
c.Mtx.Unlock()
c.WriteJSON(TypeRestrictedVisibilitySet, visibility)
}

View File

@@ -0,0 +1,173 @@
package websockets
import (
"encoding/json"
"fmt"
"strings"
"sync"
"gopkg.in/thehowl/go-osuapi.v1"
"zxq.co/ripple/rippleapi/app/v1"
"zxq.co/ripple/rippleapi/common"
"zxq.co/x/getrank"
)
type subscribeScoresUser struct {
User int `json:"user"`
Modes []int `json:"modes"`
}
// SubscribeScores subscribes a connection to score updates.
func SubscribeScores(c *conn, message incomingMessage) {
var ssu []subscribeScoresUser
err := json.Unmarshal(message.Data, &ssu)
if err != nil {
c.WriteJSON(TypeInvalidMessage, err.Error())
return
}
scoreSubscriptionsMtx.Lock()
var found bool
for idx, el := range scoreSubscriptions {
// already exists, change the users
if el.Conn.ID == c.ID {
found = true
scoreSubscriptions[idx].Users = ssu
}
}
// if it was not found, we need to add it
if !found {
scoreSubscriptions = append(scoreSubscriptions, scoreSubscription{c, ssu})
}
scoreSubscriptionsMtx.Unlock()
c.WriteJSON(TypeSubscribedToScores, ssu)
}
type scoreSubscription struct {
Conn *conn
Users []subscribeScoresUser
}
var scoreSubscriptions []scoreSubscription
var scoreSubscriptionsMtx = new(sync.RWMutex)
func scoreRetriever() {
ps, err := red.Subscribe("api:score_submission")
if err != nil {
fmt.Println(err)
}
for {
msg, err := ps.ReceiveMessage()
if err != nil {
fmt.Println(err.Error())
return
}
go handleNewScore(msg.Payload)
}
}
type scoreUser struct {
UserID int `json:"id"`
Username string `json:"username"`
Privileges uint64 `json:"privileges"`
}
type score struct {
v1.Score
scoreUser
}
type scoreJSON struct {
v1.Score
UserID int `json:"user_id"`
User scoreUser `json:"user"`
}
func handleNewScore(id string) {
defer catchPanic()
var s score
err := db.Get(&s, `
SELECT
s.id, s.beatmap_md5, s.score, s.max_combo, s.full_combo, s.mods,
s.300_count, s.100_count, s.50_count, s.gekis_count, s.katus_count, s.misses_count,
s.time, s.play_mode, s.accuracy, s.pp, s.completed, s.userid AS user_id,
u.username, u.privileges
FROM scores s
INNER JOIN users u ON s.userid = u.id
WHERE s.id = ?`, id)
if err != nil {
fmt.Println(err)
return
}
s.Rank = strings.ToUpper(getrank.GetRank(
osuapi.Mode(s.PlayMode),
osuapi.Mods(s.Mods),
s.Accuracy,
s.Count300,
s.Count100,
s.Count50,
s.CountMiss,
))
sj := scoreJSON{
Score: s.Score,
UserID: s.UserID,
User: s.scoreUser,
}
scoreSubscriptionsMtx.RLock()
cp := make([]scoreSubscription, len(scoreSubscriptions))
copy(cp, scoreSubscriptions)
scoreSubscriptionsMtx.RUnlock()
for _, el := range cp {
if len(el.Users) > 0 && !scoreUserValid(el.Users, sj) {
continue
}
if sj.User.Privileges&3 != 3 && !el.Conn.RestrictedVisible {
continue
}
el.Conn.WriteJSON(TypeNewScore, sj)
}
}
func scoreUserValid(users []subscribeScoresUser, s scoreJSON) bool {
for _, u := range users {
if u.User == s.UserID {
if len(u.Modes) > 0 {
if !inModes(u.Modes, s.PlayMode) {
return false
}
}
return true
}
}
return false
}
func inModes(modes []int, i int) bool {
for _, m := range modes {
if m == i {
return true
}
}
return false
}
func catchPanic() {
r := recover()
if r != nil {
switch r := r.(type) {
case error:
common.WSErr(r)
default:
fmt.Println("PANIC", r)
}
}
}

View File

@@ -0,0 +1,42 @@
// Package websockets implements functionality related to the API websockets.
package websockets
import (
"github.com/jmoiron/sqlx"
"gopkg.in/redis.v5"
)
var (
red *redis.Client
db *sqlx.DB
)
// Start begins websocket functionality
func Start(r *redis.Client, _db *sqlx.DB) error {
red = r
db = _db
go scoreRetriever()
go matchRetriever()
return nil
}
func cleanup(connID uint64) {
scoreSubscriptionsMtx.Lock()
for idx, el := range scoreSubscriptions {
if el.Conn.ID == connID {
scoreSubscriptions[idx] = scoreSubscriptions[len(scoreSubscriptions)-1]
scoreSubscriptions = scoreSubscriptions[:len(scoreSubscriptions)-1]
break
}
}
scoreSubscriptionsMtx.Unlock()
multiSubscriptionsMtx.Lock()
for idx, el := range multiSubscriptions {
if el.ID == connID {
multiSubscriptions[idx] = multiSubscriptions[len(multiSubscriptions)-1]
multiSubscriptions = multiSubscriptions[:len(multiSubscriptions)-1]
break
}
}
multiSubscriptionsMtx.Unlock()
}