def account_get(username_or_id): """ Returns Account --- tags: - Accounts responses: 200: description: Returns Account schema: $ref: '#/definitions/Account' """ if username_or_id.isdigit(): # an int is DB ID user = User.query.filter(User.id == int(username_or_id)).first() else: # a string is Local User user = User.query.filter(User.name == username_or_id, User.local.is_(True)).first() if not user: abort(404) if len(user.actor) != 1: abort(404) relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, user) account = to_json_account(user, relationship) return jsonify(account)
def account_get(username_or_id): """ Returns Account --- tags: - Accounts responses: 200: description: Returns Account schema: $ref: '#/definitions/Account' """ user = User.query.filter(User.name == username_or_id, User.local.is_(True)).first() if not user: try: user = User.query.filter(User.flake_id == username_or_id).first() except sqlalchemy.exc.DataError: abort(404) if not user: abort(404) if len(user.actor) != 1: abort(404) relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, user) account = to_json_account(user, relationship) return jsonify(account)
def get(username_or_id, albumslug): """ Get album details. --- tags: - Albums parameters: - name: user_id in: path type: integer required: true description: User ID - name: albumslug in: path type: string required: true description: Album slug responses: 200: description: Returns album object. """ # Get logged in user from bearer token, or None if not logged in if current_token: current_user = current_token.user else: current_user = None # Get the associated User from url fetch if username_or_id.isdigit(): album_user = User.query.filter(User.id == username_or_id).first() else: album_user = User.query.filter(User.name == username_or_id, User.local.is_(True)).first() if not album_user: return jsonify({"error": "User not found"}), 404 if current_user and album_user.id == current_user.id: # we have a valid token, and album user is token user, can fetch private album = Album.query.filter(Album.slug == albumslug, Album.user_id == album_user.id).first() else: # only fetch public ones album = Album.query.filter( Album.slug == albumslug, Album.user_id == album_user.id, Album.private.is_(False) ).first() if not album: return jsonify({"error": "not found"}), 404 if album.private: if current_user: if album.user_id != current_user.id: return jsonify({"error": "forbidden"}), 403 else: return jsonify({"error": "forbidden"}), 403 relationship = to_json_relationship(current_user, album.user) account = to_json_account(album.user, relationship) resp = to_json_album(album, account) return jsonify(resp)
def following(user_id): """ Accounts followed by the given account. --- tags: - Accounts parameters: - name: id in: path type: integer required: true description: User ID to follow - name: count in: query type: integer required: true description: count per page - name: page in: query type: integer description: page number responses: 200: description: Returns paginated array of Account schema: $ref: '#/definitions/Account' """ user = User.query.filter(User.id == user_id).first() if not user: abort(404) count = int(request.args.get("count", 20)) page = int(request.args.get("page", 1)) q = user.actor[0].followings q = q.paginate(page=page, per_page=count) followings = [] for t in q.items: # Note: the items are Follower(actor, target) # Where actor is `user` since we are asking his followers # And target = the user following `user` relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, t.target.user) account = to_json_account(t.target.user, relationship) followings.append(account) resp = { "page": page, "page_size": count, "totalItems": q.total, "items": followings, "totalPages": q.pages } return jsonify(resp)
def albums(): """ User albums timeline. --- tags: - Timelines parameters: - name: count in: query type: integer required: true description: count - name: page in: query type: integer description: page number - name: user in: query type: string description: the user flake id to get albums list responses: 200: description: Returns array of Status """ count = int(request.args.get("count", 20)) page = int(request.args.get("page", 1)) user = request.args.get("user", None) if not user: abort(400) user = User.query.filter(User.flake_id == user).first() if not user: return jsonify({"error": "User does not exist"}), 404 q = Album.query.order_by(Album.created.desc()) only_public = True if current_token and current_token.user: if user.id == current_token.user.id: only_public = False if only_public: q = q.filter(Album.user_id == user.id, Album.private.is_(False)) else: q = q.filter(Album.user_id == current_token.user.id) q = q.paginate(page=page, per_page=count) albums = [] for t in q.items: relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, t.user) account = to_json_account(t.user, relationship) albums.append(to_json_album(t, account)) resp = {"page": page, "page_size": count, "totalItems": q.total, "items": albums, "totalPages": q.pages} return jsonify(resp)
def follow(username_or_id): """ Follow an account. --- tags: - Accounts parameters: - name: id in: query type: integer required: true description: User ID to follow responses: 200: description: Returns Relationship schema: $ref: '#/definitions/Relationship' """ current_user = current_token.user if not current_user: abort(400) user = User.query.filter(User.name == username_or_id, User.local.is_(True)).first() if not user: try: user = User.query.filter(User.flake_id == username_or_id).first() except sqlalchemy.exc.DataError: abort(404) if not user: abort(404) actor_me = current_user.actor[0] actor_them = user.actor[0] if user.local: actor_me.follow(None, actor_them) return jsonify([to_json_relationship(current_user, user)]) else: # We need to initiate a follow request # Check if not already following rel = Follower.query.filter( Follower.actor_id == actor_me.id, Follower.target_id == actor_them.id).first() if not rel: # Initiate a Follow request from actor_me to actor_target follow = ap.Follow(actor=actor_me.url, object=actor_them.url) post_to_outbox(follow) return jsonify(""), 202 else: return jsonify({"error": "already following"}), 409
def unprocessed(): """ User unprocessed tracks timeline. --- tags: - Timelines parameters: - name: count in: query type: integer required: true description: count - name: page in: query type: integer description: page number responses: 200: description: Returns array of Status """ user = current_token.user if not user: return jsonify({"error": "Unauthorized"}), 403 count = int(request.args.get("count", 20)) page = int(request.args.get("page", 1)) q = Sound.query.filter( Sound.user_id == user.id, Sound.transcode_state.in_( (Sound.TRANSCODE_WAITING, Sound.TRANSCODE_PROCESSING, Sound.TRANSCODE_ERROR)), ) q = q.order_by(Sound.uploaded.desc()) q = q.paginate(page=page, per_page=count) tracks = [] for t in q.items: relationship = to_json_relationship(current_token.user, t.user) account = to_json_account(t.user, relationship) tracks.append(to_json_track(t, account)) resp = { "page": page, "page_size": count, "totalItems": q.total, "items": tracks, "totalPages": q.pages } return jsonify(resp)
def reorder(username, albumslug): """ Edit album tracks order. --- tags: - Albums security: - OAuth2: - write responses: 200: description: Returns a Status with extra reel2bits params. """ current_user = current_token.user if not current_user: return jsonify({"error": "Unauthorized"}), 403 # Get the album album = Album.query.filter(Album.user_id == current_user.id, Album.slug == albumslug).first() if not album: return jsonify({"error": "Not found"}), 404 pos = 0 for track in request.json: dbt = Sound.query.filter(Sound.flake_id == track["id"], Sound.album_id == album.id).first() if not dbt: return jsonify({"error": "Not found"}), 404 dbt.album_order = pos pos += 1 db.session.commit() relationship = to_json_relationship(current_user, album.user) account = to_json_account(album.user, relationship) resp = to_json_album(album, account) return jsonify(resp)
def unfollow(user_id): """ Unfollow an account. --- tags: - Accounts parameters: - name: id in: path type: integer required: true description: User ID to follow responses: 200: description: Returns Relationship schema: $ref: '#/definitions/Relationship' """ current_user = current_token.user if not current_user: abort(400) user = User.query.filter(User.id == user_id).first() if not user: abort(404) actor_me = current_user.actor[0] actor_them = user.actor[0] if user.local: actor_me.unfollow(actor_them) return jsonify([to_json_relationship(current_user, user)]) else: # We need to initiate a follow request # FIXME TODO abort(501)
def search(): """ Search. --- tags: - Global parameters: - name: q in: query type: string required: true description: search string responses: 200: description: fixme. """ # Get logged in user from bearer token, or None if not logged in if current_token: current_user = current_token.user else: current_user = None s = request.args.get("q", None) if not s: return jsonify({"error": "No search string provided"}), 400 # This is the old search endpoint and needs to be improved # Especially tracks and accounts needs to be returned in the right format, with the data helpers # Users should be searched from known Actors or fetched # URI should be searched from known activities or fetched # FTS, well, FTS needs to be implemented results = {"accounts": [], "sounds": [], "mode": None, "from": None} if current_user: results["from"] = current_user.name # Search for sounds # TODO: Implement FTS to get sounds search sounds = [] # Search for accounts accounts = [] is_user_at_account = RE_ACCOUNT.match(s) if s.startswith("https://"): # Try to match the URI from Activities in database results["mode"] = "uri" users = Actor.query.filter(Actor.meta_deleted.is_(False), Actor.url == s).all() elif is_user_at_account: # It matches [email protected], try to match it locally results["mode"] = "acct" user = is_user_at_account.group("user") instance = is_user_at_account.group("instance") users = Actor.query.filter(Actor.meta_deleted.is_(False), Actor.preferred_username == user, Actor.domain == instance).all() else: # It's a FTS search results["mode"] = "username" # Match actor username in database if current_user: users = (db.session.query(Actor, Follower).outerjoin( Follower, and_(Actor.id == Follower.target_id, Follower.actor_id == current_user.actor[0].id)).filter( or_(Actor.preferred_username.contains(s), Actor.name.contains(s))).filter( not_(Actor.id == current_user.actor[0].id)).all()) else: users = (db.session.query(Actor).filter( or_(Actor.preferred_username.contains(s), Actor.name.contains(s))).all()) # Handle the found users if len(users) > 0: for actor in users: relationship = False if current_user: relationship = to_json_relationship(current_user, actor.user) accounts.append(to_json_account(actor.user, relationship)) if len(accounts) <= 0: # Do a webfinger # TODO FIXME: We should do this only if https:// or user@account submitted # And rework it slightly differently since we needs to backend.fetch_iri() for https:// who # can match a Sound and not only an Actor current_app.logger.debug(f"webfinger for {s}") try: remote_actor_url = get_actor_url(s, debug=current_app.debug) # We need to get the remote Actor backend = ap.get_backend() iri = backend.fetch_iri(remote_actor_url) if iri: # We have fetched an unknown Actor # Save it in database and return it properly current_app.logger.debug( f"got remote actor URL {remote_actor_url}") act = ap.parse_activity(iri) fetched_actor, fetched_user = create_remote_actor(act) db.session.add(fetched_user) db.session.add(fetched_actor) db.session.commit() relationship = False if current_user: relationship = to_json_relationship( current_user, fetched_user) accounts.append(to_json_account(fetched_user, relationship)) results["mode"] = "webfinger" except (InvalidURLError, ValueError): current_app.logger.exception(f"Invalid AP URL: {s}") # Then test fetching as a "normal" Activity ? # Finally fill the results dict results["accounts"] = accounts # FIXME: handle exceptions if results["mode"] == "uri" and len(sounds) <= 0: backend = ap.get_backend() iri = backend.fetch_iri(s) if iri: # FIXME: Is INBOX the right choice here ? backend.save(Box.INBOX, iri) # Fetch again, but get it from database activity = Activity.query.filter(Activity.url == iri).first() if not activity: current_app.logger.exception("WTF Activity is not saved") else: from tasks import create_sound_for_remote_track, upload_workflow sound_id = create_sound_for_remote_track(activity) sound = Sound.query.filter(Sound.id == sound_id).one() upload_workflow.delay(sound.id) relationship = False if current_user: relationship = to_json_relationship(current_user, sound.user) acct = to_json_account(sound.user, relationship) sounds.append(to_json_track(sound, acct)) return jsonify({"who": s, "results": results})
def relationships(): """ Relationship of the user to the given accounts in regards to following, blocking, muting, etc. --- tags: - Accounts definitions: Relationship: type: object properties: id: type: string nullable: false following: type: boolean nullable: false followed_by: type: boolean nullable: false blocking: type: boolean nullable: false muting: type: boolean nullable: false muting_notifications: type: boolean nullable: false requested: type: boolean nullable: false domain_blocking: type: boolean nullable: false showing_reblogs: type: boolean nullable: false endorsed: type: boolean nullable: false parameters: - name: id in: query type: array required: true items: type: integer description: Array of account IDs responses: 200: description: Returns array of Relationship schema: $ref: '#/definitions/Relationship' """ ids = request.args.getlist("id") of_user = current_token.user rels = [] for id in ids: against_user = User.query.filter(User.id == id).first() if not against_user: if len(ids) > 1: next else: return jsonify([]) rels.append(to_json_relationship(of_user, against_user)) return jsonify(rels)
def user_statuses(user_id): """ User statuses. --- tags: - Timelines parameters: - name: count in: query type: integer required: true description: count per page - name: with_muted in: query type: boolean required: true description: with muted users - name: page in: query type: integer description: page number responses: 200: description: Returns array of Status """ # Caveats: only handle public Sounds since we either federate (public) or no count = int(request.args.get("count", 20)) page = int(request.args.get("page", 1)) # Get associated user user = User.query.filter(User.id == user_id).first() if not user: abort(404) q = db.session.query(Activity, Sound).filter( Activity.type == "Create", Activity.payload[("object", "type")].astext == "Audio") q = q.filter(Activity.meta_deleted.is_(False)) q = q.filter(Activity.payload["to"].astext.contains( "https://www.w3.org/ns/activitystreams#Public")) q = q.filter(Activity.actor == user.actor[0].id) q = q.join(Sound, Sound.activity_id == Activity.id) q = q.order_by(Activity.creation_date.desc()) q = q.paginate(page=page, per_page=count) tracks = [] for t in q.items: if t.Sound: relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, t.Sound.user) account = to_json_account(t.Sound.user, relationship) tracks.append(to_json_track(t.Sound, account)) else: print(t.Activity) resp = { "page": page, "page_size": count, "totalItems": q.total, "items": tracks, "totalPages": q.pages } return jsonify(resp)
def edit(username, albumslug): """ Edit album. --- tags: - Albums security: - OAuth2: - write parameters: - name: username in: path type: string required: true description: User username - name: albumslug in: path type: string required: true description: Album slug responses: 200: description: Returns a Status with extra reel2bits params. """ current_user = current_token.user if not current_user: return jsonify({"error": "Unauthorized"}), 403 # Get the album album = Album.query.filter(Album.user_id == current_user.id, Album.slug == albumslug).first() if not album: return jsonify({"error": "Not found"}), 404 description = request.json.get("description") private = request.json.get("private") title = request.json.get("title") genre = request.json.get("genre") tags = request.json.get("tags") if album.private and not private: return jsonify( {"error": "Cannot change to private: album already federated"}) if not title: return jsonify({"error": "Album title is required"}), 400 album.title = title album.description = description album.genre = genre # First remove tags which have been removed for tag in album.tags: if tag.name not in tags: album.tags.remove(tag) # Then add the new ones if new for tag in tags: if tag not in [a.name for a in album.tags]: dbt = SoundTag.query.filter(SoundTag.name == tag).first() if not dbt: dbt = SoundTag(name=tag) db.session.add(dbt) album.tags.append(dbt) # Purge orphaned tags for otag in SoundTag.query.filter( and_(~SoundTag.sounds.any(), ~SoundTag.albums.any())).all(): db.session.delete(otag) db.session.commit() relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, album.user) account = to_json_account(album.user, relationship) return jsonify(to_json_album(album, account))
def unfollow(username_or_id): """ Unfollow an account. --- tags: - Accounts parameters: - name: id in: path type: integer required: true description: User ID to follow responses: 200: description: Returns Relationship schema: $ref: '#/definitions/Relationship' """ current_user = current_token.user if not current_user: abort(400) user = User.query.filter(User.name == username_or_id, User.local.is_(True)).first() if not user: try: user = User.query.filter(User.flake_id == username_or_id).first() except sqlalchemy.exc.DataError: abort(404) if not user: abort(404) actor_me = current_user.actor[0] actor_them = user.actor[0] if user.local: actor_me.unfollow(actor_them) return jsonify([to_json_relationship(current_user, user)]) else: # Get the relation of the follow follow_relation = Follower.query.filter( Follower.actor_id == actor_me.id, Follower.target_id == actor_them.id ).first() if not follow_relation: return jsonify({"error": "follow relation not found"}), 404 # Fetch the related Activity of the Follow relation accept_activity = Activity.query.filter(Activity.url == follow_relation.activity_url).first() if not accept_activity: current_app.logger.error(f"cannot find accept activity {follow_relation.activity_url}") return jsonify({"error": "cannot found the accept activity"}), 500 # Then the Activity ID of the ACcept will be the object id activity = ap.parse_activity(payload=accept_activity.payload) # get the final activity (the Follow one) follow_activity = Activity.query.filter(Activity.url == activity.get_object_id()).first() if not follow_activity: current_app.logger.error(f"cannot find follow activity {activity.get_object_id()}") return jsonify({"error": "cannot find follow activity"}), 500 ap_follow_activity = ap.parse_activity(payload=follow_activity.payload) # initiate an Undo of the Follow request unfollow = ap_follow_activity.build_undo() post_to_outbox(unfollow) return jsonify(""), 202
def show(username_or_id, soundslug): """ Get track details. --- tags: - Tracks parameters: - name: user_id in: path type: integer required: true description: User ID - name: soundslug in: path type: string required: true description: Track slug responses: 200: description: Returns track details. """ # Get logged in user from bearer token, or None if not logged in if current_token: current_user = current_token.user else: current_user = None # Get the associated User from url fetch track_user = User.query.filter(User.name == username_or_id, User.local.is_(True)).first() if not track_user: try: track_user = User.query.filter(User.flake_id == username_or_id).first() except sqlalchemy.exc.DataError: return jsonify({"error": "User not found"}), 404 if not track_user: return jsonify({"error": "User not found"}), 404 if current_user and (track_user.id == current_user.id): print("user") sound = Sound.query.filter(Sound.slug == soundslug, Sound.user_id == track_user.id).first() else: print("no user") sound = Sound.query.filter( Sound.slug == soundslug, Sound.user_id == track_user.id, Sound.transcode_state == Sound.TRANSCODE_DONE ).first() if not sound: print("mmmh") return jsonify({"error": "not found"}), 404 if sound.private: if current_user: if sound.user_id != current_user.id: return jsonify({"error": "forbidden"}), 403 else: return jsonify({"error": "forbidden"}), 403 relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, sound.user) account = to_json_account(sound.user, relationship) return jsonify(to_json_track(sound, account))
def edit(username, soundslug): """ Edit track. --- tags: - Tracks security: - OAuth2: - write parameters: - name: username in: path type: string required: true description: User username - name: soundslug in: path type: string required: true description: Track slug responses: 200: description: Returns a Status with extra reel2bits params. """ current_user = current_token.user if not current_user: return jsonify({"error": "Unauthorized"}), 403 # Get the track sound = Sound.query.filter(Sound.user_id == current_user.id, Sound.slug == soundslug).first() if not sound: return jsonify({"error": "Not found"}), 404 album = request.json.get("album") description = request.json.get("description") licence = request.json.get("licence") private = request.json.get("private") title = request.json.get("title") genre = request.json.get("genre") tags = request.json.get("tags") if sound.private and not private: return jsonify({"error": "Cannot change to private: track already federated"}) if not title: title, _ = splitext(sound.filename_orig) else: sound.title = title sound.description = description sound.licence = licence sound.genre = genre # First remove tags which have been removed for tag in sound.tags: if tag.name not in tags: sound.tags.remove(tag) # Then add the new ones if new for tag in tags: if tag not in [a.name for a in sound.tags]: dbt = SoundTag.query.filter(SoundTag.name == tag).first() if not dbt: dbt = SoundTag(name=tag) db.session.add(dbt) sound.tags.append(dbt) # Purge orphaned tags for otag in SoundTag.query.filter(and_(~SoundTag.sounds.any(), ~SoundTag.albums.any())).all(): db.session.delete(otag) # Fetch album, and associate if owner if album and (album != "__None"): db_album = Album.query.filter(Album.id == album).first() if db_album and (db_album.user_id == current_user.id): sound.album_id = db_album.id if not db_album.sounds: sound.album_order = 0 else: sound.album_order = db_album.sounds.count() + 1 elif album == "__None": sound.album_id = None sound.album_order = 0 db.session.commit() # trigger a sound update send_update_sound(sound) relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, sound.user) account = to_json_account(sound.user, relationship) return jsonify(to_json_track(sound, account))
def public(): """ Public or TWKN statuses. --- tags: - Timelines parameters: - name: count in: query type: integer required: true description: count - name: with_muted in: query type: boolean required: true description: with muted users - name: local in: query type: boolean description: local only or TWKN responses: 200: description: Returns array of Status """ # Caveats: only handle public Sounds since we either federate (public) or no paginated = request.args.get("paginated", False) count = int(request.args.get("count", 20)) local_only = request.args.get("local", False) q = db.session.query(Activity, Sound).filter( Activity.type == "Create", Activity.payload[("object", "type")].astext == "Audio" ) q = q.filter(Activity.meta_deleted.is_(False)) if local_only: q = q.filter(Activity.local.is_(True)) q = q.filter(Activity.payload["to"].astext.contains("https://www.w3.org/ns/activitystreams#Public")) q = q.join(Sound, Sound.activity_id == Activity.id) q = q.order_by(Activity.creation_date.desc()) if paginated: # Render timeline as paginated page = int(request.args.get("page", 1)) q = q.paginate(page=page, per_page=count) tracks = [] for t in q.items: if t.Sound: # TODO(dashie) FIXME can probably be moved out to the q.filter() if not t.Sound.transcode_state == Sound.TRANSCODE_DONE: continue relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, t.Sound.user) account = to_json_account(t.Sound.user, relationship) tracks.append(to_json_track(t.Sound, account)) else: print(t.Activity) resp = {"page": page, "page_size": count, "totalItems": q.total, "items": tracks, "totalPages": q.pages} return jsonify(resp) else: # mastoapi compatible since_id = request.args.get("since_id") # since then we want the timeline if since_id: q = q.filter(Sound.flake_id > since_id) # then limit count q = q.limit(count) tracks = [] for t in q.all(): if t.Sound: relationship = False if current_token and current_token.user: relationship = to_json_relationship(current_token.user, t.Sound.user) account = to_json_account(t.Sound.user, relationship) tracks.append(to_json_track(t.Sound, account)) else: print(t.Activity) return jsonify(tracks)