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 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 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 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 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 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 accounts_update_credentials(): """ Update user’s own account. --- tags: - Accounts security: - OAuth2: - write responses: 200: description: Returns Account with extra Source and Pleroma attributes. schema: allOf: - $ref: '#/definitions/Account' - $ref: '#/definitions/Source' - $ref: '#/definitions/AccountPleroma' """ current_user = current_token.user if not current_user: # WTF ? return jsonify({"error": "User not found"}), 404 # Update fields like bio, language, etc. if request.json: r_lang = request.json.get("lang", None) r_fullname = request.json.get("fullname", None) r_bio = request.json.get("bio", None) if r_lang: current_user.locale = r_lang if r_fullname: current_user.display_name = r_fullname if r_bio: current_user.actor[0].summary = r_bio elif request.files: # Update things like user background, profile picture, etc. if "avatar" in request.files: avatar_uploaded = request.files["avatar"] avatar_uploaded.seek(0, os.SEEK_END) avatar_size = avatar_uploaded.tell() avatar_uploaded.seek(0) if avatar_size > Reel2bitsDefaults.avatar_size_limit: return jsonify({"error": "artwork too big, 2MB maximum" }), 413 # Request Entity Too Large # Delete old avatar if any if current_user.avatar_filename: old_avatar = os.path.join( current_app.config["UPLOADED_AVATARS_DEST"], current_user.path_avatar()) if os.path.isfile(old_avatar): os.unlink(old_avatar) else: print( f"Error: cannot delete old avatar: {current_user.id} / {current_user.avatar_filename}" ) # Save new avatar avatar_filename = get_hashed_filename(avatar_uploaded.filename) avatars.save(avatar_uploaded, folder=current_user.slug, name=avatar_filename) current_user.avatar_filename = avatar_filename # commit changes db.session.commit() # log action add_user_log(current_user.id, current_user.id, "user", "info", "Edited user profile") # trigger a profile update send_update_profile(current_user) return jsonify(to_json_account(current_user))
def accounts_verify_credentials(): """ User’s own account. --- tags: - Accounts security: - OAuth2: - read definitions: Field: type: object properties: name: type: string nullable: false value: type: string nullable: false verified_at: type: integer nullable: true Emoji: type: object properties: shortcode: type: string nullable: false static_url: type: string format: uri nullable: false url: type: string format: uri nullable: false visible_in_picker: type: boolean nullable: false Source: type: object properties: privacy: type: string nullable: true sensitive: type: boolean nullable: true language: type: string nullable: true note: type: string nullable: false fields: type: array nullable: false items: type: object $ref: '#/definitions/Field' AccountPleroma: type: object properties: pleroma: type: object properties: is_admin: type: boolean Account: type: object properties: id: type: string nullable: false username: type: string nullable: false acct: type: string nullable: false display_name: type: integer nullable: false locked: type: boolean nullable: false created_at: type: integer nullable: false followers_count: type: integer nullable: false following_count: type: integer nullable: false statuses_count: type: integer nullable: false note: type: string nullable: false url: type: string format: uri nullable: false avatar: type: string format: uri nullable: false avatar_static: type: string format: uri nullable: false header: type: string format: uri nullable: false header_static: type: string format: uri nullable: false emojis: type: hash nullable: false items: type: object $ref: '#/definitions/Emoji' moved: type: object $ref: '#/definitions/Account' nullable: true fields: type: array nullable: true items: type: object $ref: '#/definitions/Field' bot: type: boolean nullable: true responses: 200: description: Returns Account with extra Source and Pleroma attributes. schema: allOf: - $ref: '#/definitions/Account' - $ref: '#/definitions/Source' - $ref: '#/definitions/AccountPleroma' """ user = current_token.user return jsonify(to_json_account(user))
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)
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))