appmanager-server 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appmanager/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from flask import (
|
|
4
|
+
Blueprint,
|
|
5
|
+
current_app,
|
|
6
|
+
flash,
|
|
7
|
+
make_response,
|
|
8
|
+
redirect,
|
|
9
|
+
render_template,
|
|
10
|
+
request,
|
|
11
|
+
url_for,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from appmanager.auth.oauth import get_google_auth_url, process_google_callback
|
|
15
|
+
from appmanager.auth.utils import (
|
|
16
|
+
JWT_COOKIE_NAME,
|
|
17
|
+
create_magic_link,
|
|
18
|
+
generate_jwt,
|
|
19
|
+
get_current_user,
|
|
20
|
+
login_required,
|
|
21
|
+
)
|
|
22
|
+
from appmanager.database import db
|
|
23
|
+
from appmanager.models import InstalledApp, MagicLinkToken, User, UserAppPermission
|
|
24
|
+
from appmanager.security import check_rate_limit, is_safe_redirect_url
|
|
25
|
+
|
|
26
|
+
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@auth_bp.route("/login", methods=["GET"])
|
|
30
|
+
def login():
|
|
31
|
+
user = get_current_user()
|
|
32
|
+
if user:
|
|
33
|
+
return redirect(url_for("auth.profile"))
|
|
34
|
+
|
|
35
|
+
next_url = request.args.get("next", "")
|
|
36
|
+
if next_url and not is_safe_redirect_url(next_url):
|
|
37
|
+
next_url = ""
|
|
38
|
+
google_configured = bool(current_app.config.get("GOOGLE_CLIENT_ID"))
|
|
39
|
+
return render_template("auth/login.html", next=next_url, google_configured=google_configured)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@auth_bp.route("/magic-link", methods=["POST"])
|
|
43
|
+
def request_magic_link():
|
|
44
|
+
client_ip = request.remote_addr or "unknown"
|
|
45
|
+
if not check_rate_limit(f"magic_link_{client_ip}", limit=5, window_seconds=60):
|
|
46
|
+
flash("Too many login requests. Please wait a minute and try again.", "danger")
|
|
47
|
+
return redirect(url_for("auth.login"))
|
|
48
|
+
|
|
49
|
+
email = request.form.get("email", "").strip().lower()
|
|
50
|
+
next_url = request.form.get("next", "")
|
|
51
|
+
if next_url and not is_safe_redirect_url(next_url):
|
|
52
|
+
next_url = ""
|
|
53
|
+
|
|
54
|
+
if not email or "@" not in email:
|
|
55
|
+
flash("Please enter a valid email address.", "warning")
|
|
56
|
+
return redirect(url_for("auth.login", next=next_url))
|
|
57
|
+
|
|
58
|
+
magic_url = create_magic_link(email)
|
|
59
|
+
|
|
60
|
+
# In dev mode without SMTP, pass the magic link URL to the template for quick developer login
|
|
61
|
+
dev_magic_url = None
|
|
62
|
+
if not current_app.config.get("SMTP_SERVER"):
|
|
63
|
+
dev_magic_url = magic_url
|
|
64
|
+
|
|
65
|
+
return render_template(
|
|
66
|
+
"auth/login.html",
|
|
67
|
+
magic_sent=True,
|
|
68
|
+
email=email,
|
|
69
|
+
dev_magic_url=dev_magic_url,
|
|
70
|
+
google_configured=bool(current_app.config.get("GOOGLE_CLIENT_ID")),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@auth_bp.route("/verify-magic", methods=["GET"])
|
|
75
|
+
def verify_magic():
|
|
76
|
+
token_str = request.args.get("token")
|
|
77
|
+
if not token_str:
|
|
78
|
+
flash("Invalid or missing magic link token.", "danger")
|
|
79
|
+
return redirect(url_for("auth.login"))
|
|
80
|
+
|
|
81
|
+
magic_token = MagicLinkToken.query.filter_by(token=token_str).first()
|
|
82
|
+
if not magic_token or magic_token.used:
|
|
83
|
+
flash("This magic link is invalid or has already been used.", "danger")
|
|
84
|
+
return redirect(url_for("auth.login"))
|
|
85
|
+
|
|
86
|
+
# Check expiration (naive/aware comparison handling)
|
|
87
|
+
expires_at = magic_token.expires_at
|
|
88
|
+
if expires_at.tzinfo is None:
|
|
89
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
90
|
+
|
|
91
|
+
if datetime.now(timezone.utc) > expires_at:
|
|
92
|
+
flash("This magic link has expired. Please request a new one.", "warning")
|
|
93
|
+
return redirect(url_for("auth.login"))
|
|
94
|
+
|
|
95
|
+
# Mark token as used
|
|
96
|
+
magic_token.used = True
|
|
97
|
+
db.session.commit()
|
|
98
|
+
|
|
99
|
+
# Find or create user
|
|
100
|
+
user = User.query.filter_by(email=magic_token.email).first()
|
|
101
|
+
if not user:
|
|
102
|
+
# First user created in the system becomes Admin automatically
|
|
103
|
+
user_count = User.query.count()
|
|
104
|
+
user_role = "admin" if user_count == 0 else "user"
|
|
105
|
+
user = User(
|
|
106
|
+
email=magic_token.email,
|
|
107
|
+
name=magic_token.email.split("@")[0].capitalize(),
|
|
108
|
+
role=user_role,
|
|
109
|
+
)
|
|
110
|
+
db.session.add(user)
|
|
111
|
+
db.session.commit()
|
|
112
|
+
|
|
113
|
+
# If non-admin user, assign default permissions for active apps
|
|
114
|
+
if user_role != "admin":
|
|
115
|
+
active_apps = InstalledApp.query.filter_by(is_active=True).all()
|
|
116
|
+
for app in active_apps:
|
|
117
|
+
perm = UserAppPermission(user_id=user.id, app_id=app.id, can_access=True)
|
|
118
|
+
db.session.add(perm)
|
|
119
|
+
db.session.commit()
|
|
120
|
+
|
|
121
|
+
# Track login metrics
|
|
122
|
+
user.last_login_at = datetime.now(timezone.utc)
|
|
123
|
+
user.last_active_at = datetime.now(timezone.utc)
|
|
124
|
+
user.login_count = (user.login_count or 0) + 1
|
|
125
|
+
user.last_ip = request.remote_addr
|
|
126
|
+
db.session.commit()
|
|
127
|
+
|
|
128
|
+
jwt_token = generate_jwt(user)
|
|
129
|
+
flash(f"Welcome back, {user.name}!", "success")
|
|
130
|
+
|
|
131
|
+
next_url = request.args.get("next", "")
|
|
132
|
+
target_redirect = next_url if is_safe_redirect_url(next_url) else url_for("auth.profile")
|
|
133
|
+
|
|
134
|
+
response = make_response(redirect(target_redirect))
|
|
135
|
+
|
|
136
|
+
response.set_cookie(
|
|
137
|
+
JWT_COOKIE_NAME,
|
|
138
|
+
jwt_token,
|
|
139
|
+
httponly=True,
|
|
140
|
+
samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax"),
|
|
141
|
+
secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
|
|
142
|
+
max_age=7 * 24 * 3600,
|
|
143
|
+
)
|
|
144
|
+
return response
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@auth_bp.route("/google")
|
|
148
|
+
def google_login():
|
|
149
|
+
auth_url, error = get_google_auth_url()
|
|
150
|
+
if error:
|
|
151
|
+
flash(f"Google OAuth Error: {error}", "danger")
|
|
152
|
+
return redirect(url_for("auth.login"))
|
|
153
|
+
return redirect(auth_url)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@auth_bp.route("/google/callback")
|
|
157
|
+
def google_callback():
|
|
158
|
+
user_data, error = process_google_callback()
|
|
159
|
+
if error:
|
|
160
|
+
flash(f"Google Login Failed: {error}", "danger")
|
|
161
|
+
return redirect(url_for("auth.login"))
|
|
162
|
+
|
|
163
|
+
email = user_data.get("email", "").lower()
|
|
164
|
+
google_id = user_data.get("sub")
|
|
165
|
+
name = user_data.get("name", email.split("@")[0])
|
|
166
|
+
|
|
167
|
+
if not email:
|
|
168
|
+
flash("Could not retrieve email from Google account.", "danger")
|
|
169
|
+
return redirect(url_for("auth.login"))
|
|
170
|
+
|
|
171
|
+
user = User.query.filter((User.google_id == google_id) | (User.email == email)).first()
|
|
172
|
+
if not user:
|
|
173
|
+
user_count = User.query.count()
|
|
174
|
+
user_role = "admin" if user_count == 0 else "user"
|
|
175
|
+
user = User(email=email, name=name, google_id=google_id, role=user_role)
|
|
176
|
+
db.session.add(user)
|
|
177
|
+
db.session.commit()
|
|
178
|
+
else:
|
|
179
|
+
if not user.google_id:
|
|
180
|
+
user.google_id = google_id
|
|
181
|
+
db.session.commit()
|
|
182
|
+
|
|
183
|
+
jwt_token = generate_jwt(user)
|
|
184
|
+
flash(f"Logged in with Google as {user.email}", "success")
|
|
185
|
+
|
|
186
|
+
response = make_response(redirect(url_for("auth.profile")))
|
|
187
|
+
response.set_cookie(
|
|
188
|
+
JWT_COOKIE_NAME,
|
|
189
|
+
jwt_token,
|
|
190
|
+
httponly=True,
|
|
191
|
+
samesite=current_app.config.get("SESSION_COOKIE_SAMESITE", "Lax"),
|
|
192
|
+
secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
|
|
193
|
+
max_age=7 * 24 * 3600,
|
|
194
|
+
)
|
|
195
|
+
return response
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@auth_bp.route("/logout")
|
|
199
|
+
def logout():
|
|
200
|
+
flash("Logged out successfully.", "info")
|
|
201
|
+
response = make_response(redirect(url_for("auth.login")))
|
|
202
|
+
response.delete_cookie(JWT_COOKIE_NAME)
|
|
203
|
+
return response
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@auth_bp.route("/profile")
|
|
207
|
+
@login_required
|
|
208
|
+
def profile():
|
|
209
|
+
user = get_current_user()
|
|
210
|
+
if user.is_admin():
|
|
211
|
+
accessible_apps = InstalledApp.query.filter_by(is_active=True).all()
|
|
212
|
+
else:
|
|
213
|
+
accessible_apps = (
|
|
214
|
+
db.session.query(InstalledApp)
|
|
215
|
+
.join(UserAppPermission, UserAppPermission.app_id == InstalledApp.id)
|
|
216
|
+
.filter(
|
|
217
|
+
UserAppPermission.user_id == user.id,
|
|
218
|
+
UserAppPermission.can_access.is_(True),
|
|
219
|
+
InstalledApp.is_active.is_(True),
|
|
220
|
+
)
|
|
221
|
+
.all()
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return render_template("auth/profile.html", user=user, apps=accessible_apps)
|
appmanager/auth/utils.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import smtplib
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from email.mime.multipart import MIMEMultipart
|
|
5
|
+
from email.mime.text import MIMEText
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
from flask import current_app, flash, g, redirect, request, url_for
|
|
10
|
+
|
|
11
|
+
from appmanager.database import db
|
|
12
|
+
from appmanager.models import MagicLinkToken, User
|
|
13
|
+
|
|
14
|
+
JWT_COOKIE_NAME = "appmanager_jwt"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_jwt(user):
|
|
18
|
+
payload = {
|
|
19
|
+
"user_id": user.id,
|
|
20
|
+
"email": user.email,
|
|
21
|
+
"role": user.role,
|
|
22
|
+
"exp": datetime.now(timezone.utc) + current_app.config["JWT_ACCESS_TOKEN_EXPIRES"],
|
|
23
|
+
"iat": datetime.now(timezone.utc),
|
|
24
|
+
}
|
|
25
|
+
token = jwt.encode(payload, current_app.config["JWT_SECRET"], algorithm="HS256")
|
|
26
|
+
return token
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decode_jwt(token):
|
|
30
|
+
try:
|
|
31
|
+
payload = jwt.decode(token, current_app.config["JWT_SECRET"], algorithms=["HS256"])
|
|
32
|
+
return payload
|
|
33
|
+
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_current_user():
|
|
38
|
+
if hasattr(g, "current_user") and g.current_user is not None:
|
|
39
|
+
return g.current_user
|
|
40
|
+
|
|
41
|
+
token = None
|
|
42
|
+
# 1. Check Authorization header
|
|
43
|
+
auth_header = request.headers.get("Authorization")
|
|
44
|
+
if auth_header and auth_header.startswith("Bearer "):
|
|
45
|
+
token = auth_header.split(" ")[1]
|
|
46
|
+
|
|
47
|
+
# 2. Check HTTP-only cookie
|
|
48
|
+
if not token:
|
|
49
|
+
token = request.cookies.get(JWT_COOKIE_NAME)
|
|
50
|
+
|
|
51
|
+
if not token:
|
|
52
|
+
g.current_user = None
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
payload = decode_jwt(token)
|
|
56
|
+
if not payload:
|
|
57
|
+
g.current_user = None
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
user = db.session.get(User, payload["user_id"])
|
|
61
|
+
if user and user.is_active:
|
|
62
|
+
# Update last_active_at timestamp (throttled to once per minute)
|
|
63
|
+
now = datetime.now(timezone.utc)
|
|
64
|
+
last_act = user.last_active_at
|
|
65
|
+
if last_act and last_act.tzinfo is None:
|
|
66
|
+
last_act = last_act.replace(tzinfo=timezone.utc)
|
|
67
|
+
if not last_act or (now - last_act).total_seconds() > 60:
|
|
68
|
+
user.last_active_at = now
|
|
69
|
+
try:
|
|
70
|
+
db.session.commit()
|
|
71
|
+
except Exception:
|
|
72
|
+
db.session.rollback()
|
|
73
|
+
|
|
74
|
+
g.current_user = user
|
|
75
|
+
return user
|
|
76
|
+
|
|
77
|
+
g.current_user = None
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def login_required(f):
|
|
82
|
+
@wraps(f)
|
|
83
|
+
def decorated_function(*args, **kwargs):
|
|
84
|
+
user = get_current_user()
|
|
85
|
+
if not user:
|
|
86
|
+
if request.is_json or request.path.startswith("/api/"):
|
|
87
|
+
return {"error": "Authentication required"}, 401
|
|
88
|
+
return redirect(url_for("auth.login", next=request.url))
|
|
89
|
+
return f(*args, **kwargs)
|
|
90
|
+
|
|
91
|
+
return decorated_function
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def admin_required(f):
|
|
95
|
+
@wraps(f)
|
|
96
|
+
def decorated_function(*args, **kwargs):
|
|
97
|
+
user = get_current_user()
|
|
98
|
+
if not user:
|
|
99
|
+
if request.is_json or request.path.startswith("/api/"):
|
|
100
|
+
return {"error": "Authentication required"}, 401
|
|
101
|
+
return redirect(url_for("auth.login", next=request.url))
|
|
102
|
+
if not user.is_admin():
|
|
103
|
+
if request.is_json or request.path.startswith("/api/"):
|
|
104
|
+
return {"error": "Admin privilege required"}, 403
|
|
105
|
+
flash("Admin privilege required to access this area.", "danger")
|
|
106
|
+
return redirect(url_for("auth.profile"))
|
|
107
|
+
return f(*args, **kwargs)
|
|
108
|
+
|
|
109
|
+
return decorated_function
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_magic_link(email):
|
|
113
|
+
token_str = secrets.token_urlsafe(32)
|
|
114
|
+
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
115
|
+
minutes=current_app.config["MAGIC_LINK_EXPIRES_MINUTES"]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Clean up old unused tokens for this email
|
|
119
|
+
MagicLinkToken.query.filter_by(email=email, used=False).delete()
|
|
120
|
+
|
|
121
|
+
magic_token = MagicLinkToken(email=email, token=token_str, expires_at=expires_at, used=False)
|
|
122
|
+
db.session.add(magic_token)
|
|
123
|
+
db.session.commit()
|
|
124
|
+
|
|
125
|
+
if request:
|
|
126
|
+
base_url = request.host_url.rstrip("/")
|
|
127
|
+
else:
|
|
128
|
+
base_url = current_app.config["APP_BASE_URL"].rstrip("/")
|
|
129
|
+
|
|
130
|
+
verify_url = f"{base_url}/auth/verify-magic?token={token_str}"
|
|
131
|
+
|
|
132
|
+
send_magic_link_email(email, verify_url)
|
|
133
|
+
return verify_url
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def send_magic_link_email(email, magic_url):
|
|
137
|
+
smtp_server = current_app.config.get("SMTP_SERVER")
|
|
138
|
+
if smtp_server:
|
|
139
|
+
try:
|
|
140
|
+
msg = MIMEMultipart("alternative")
|
|
141
|
+
msg["Subject"] = "Your Magic Login Link - AppManager"
|
|
142
|
+
msg["From"] = current_app.config["MAIL_DEFAULT_SENDER"]
|
|
143
|
+
msg["To"] = email
|
|
144
|
+
|
|
145
|
+
text = f"Click here to log into AppManager: {magic_url}\nThis link expires in {current_app.config['MAGIC_LINK_EXPIRES_MINUTES']} minutes."
|
|
146
|
+
html = f"""
|
|
147
|
+
<html>
|
|
148
|
+
<body>
|
|
149
|
+
<h2>AppManager Login</h2>
|
|
150
|
+
<p>Click the button below to log in to your account. This link will expire in {current_app.config["MAGIC_LINK_EXPIRES_MINUTES"]} minutes.</p>
|
|
151
|
+
<p><a href="{magic_url}" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Log In to AppManager</a></p>
|
|
152
|
+
<p>Or copy this link into your browser: <br><code>{magic_url}</code></p>
|
|
153
|
+
</body>
|
|
154
|
+
</html>
|
|
155
|
+
"""
|
|
156
|
+
msg.attach(MIMEText(text, "plain"))
|
|
157
|
+
msg.attach(MIMEText(html, "html"))
|
|
158
|
+
|
|
159
|
+
with smtplib.SMTP(smtp_server, current_app.config["SMTP_PORT"]) as server:
|
|
160
|
+
server.starttls()
|
|
161
|
+
server.login(current_app.config["SMTP_USER"], current_app.config["SMTP_PASSWORD"])
|
|
162
|
+
server.send_message(msg)
|
|
163
|
+
print(f"[AUTH] Magic link email successfully sent to {email}")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
print(
|
|
166
|
+
f"[AUTH ERROR] Failed to send email via SMTP: {e}. Falling back to console output."
|
|
167
|
+
)
|
|
168
|
+
print(f"================ MAGIC LINK FOR {email} ================")
|
|
169
|
+
print(f"URL: {magic_url}")
|
|
170
|
+
print("=======================================================")
|
|
171
|
+
else:
|
|
172
|
+
print("\n================ MAGIC LINK (DEV CONSOLE LOG) ================")
|
|
173
|
+
print(f"Target Email: {email}")
|
|
174
|
+
print(f"Login URL: {magic_url}")
|
|
175
|
+
print("==============================================================\n")
|
appmanager/bridge.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any, Dict, Optional, Union
|
|
4
|
+
|
|
5
|
+
from appmanager.database import db
|
|
6
|
+
from appmanager.models import AppTelemetryLog
|
|
7
|
+
from appmanager.signals import telemetry_received
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def report_event(app_slug: str, event_type: str, data: Optional[Dict[str, Any]] = None) -> bool:
|
|
11
|
+
"""
|
|
12
|
+
Sub-apps can call report_event to log events directly to AppManager host telemetry.
|
|
13
|
+
Zero-network overhead in-process communication.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
payload = json.dumps(data) if data is not None else None
|
|
17
|
+
log_entry = AppTelemetryLog(
|
|
18
|
+
app_slug=app_slug,
|
|
19
|
+
event_type=event_type,
|
|
20
|
+
payload_json=payload,
|
|
21
|
+
created_at=datetime.now(timezone.utc),
|
|
22
|
+
)
|
|
23
|
+
db.session.add(log_entry)
|
|
24
|
+
db.session.commit()
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
telemetry_received.send(None, app_slug=app_slug, event_type=event_type, data=data)
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
return True
|
|
32
|
+
except Exception as e:
|
|
33
|
+
db.session.rollback()
|
|
34
|
+
print(f"[BRIDGE ERROR] Failed to record event for '{app_slug}': {e}")
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def report_metric(
|
|
39
|
+
app_slug: str, metric_name: str, value: Union[int, float], unit: Optional[str] = None
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Sub-apps can call report_metric to log numeric metrics.
|
|
43
|
+
"""
|
|
44
|
+
data = {"value": value}
|
|
45
|
+
if unit:
|
|
46
|
+
data["unit"] = unit
|
|
47
|
+
return report_event(app_slug, event_type=f"metric:{metric_name}", data=data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_current_user_from_headers(headers: Optional[Any] = None) -> Optional[Dict[str, Any]]:
|
|
51
|
+
"""
|
|
52
|
+
Helper for sub-apps to extract the authenticated user identity forwarded by AppManager.
|
|
53
|
+
If headers is None, attempts to read from flask.request.headers or os.environ.
|
|
54
|
+
"""
|
|
55
|
+
if headers is None:
|
|
56
|
+
try:
|
|
57
|
+
from flask import request
|
|
58
|
+
|
|
59
|
+
headers = request.headers
|
|
60
|
+
except Exception:
|
|
61
|
+
headers = {}
|
|
62
|
+
|
|
63
|
+
user_id = headers.get("X-AppManager-User-Id") or headers.get("HTTP_X_APPMANAGER_USER_ID")
|
|
64
|
+
user_email = headers.get("X-AppManager-User-Email") or headers.get(
|
|
65
|
+
"HTTP_X_APPMANAGER_USER_EMAIL"
|
|
66
|
+
)
|
|
67
|
+
user_role = headers.get("X-AppManager-User-Role") or headers.get("HTTP_X_APPMANAGER_USER_ROLE")
|
|
68
|
+
|
|
69
|
+
if user_id or user_email:
|
|
70
|
+
return {
|
|
71
|
+
"id": int(user_id) if user_id and str(user_id).isdigit() else user_id,
|
|
72
|
+
"email": user_email,
|
|
73
|
+
"role": user_role or "user",
|
|
74
|
+
}
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_app_settings(app_slug: str) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Helper to retrieve configured settings dictionary for a sub-app.
|
|
81
|
+
"""
|
|
82
|
+
from appmanager.models import InstalledApp
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
app_rec = InstalledApp.query.filter_by(slug=app_slug).first()
|
|
86
|
+
if app_rec:
|
|
87
|
+
return app_rec.get_settings()
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
return {}
|