6 Commits

87 changed files with 3408 additions and 6534 deletions
-3
View File
@@ -576,7 +576,4 @@ backend/alembic/**
!backend/alembic/script.py.mako !backend/alembic/script.py.mako
!frontend/src/css/lib !frontend/src/css/lib
**/*.module.scss.d.ts **/*.module.scss.d.ts
.cursor/plans .cursor/plans
tmp
compliance_keypair.txt
+78 -65
View File
@@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
Preamble Preamble
The GNU Affero General Public License is a free, copyleft license for The GNU General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure software and other kinds of works.
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights To protect your rights, we need to prevent others from denying you
with two steps: (1) assert copyright on the software, and (2) offer these rights or asking you to surrender the rights. Therefore, you have
you this License which gives you legal permission to copy, distribute certain responsibilities if you distribute copies of the software, or if
and/or modify the software. you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that For example, if you distribute copies of such a program, whether
improvements made in alternate versions of the program, if they gratis or for a fee, you must pass on to the recipients the same
receive widespread use, become available for other developers to freedoms that you received. You must make sure that they, too, receive
incorporate. Many developers of free software are heartened and or can get the source code. And you must show them these terms so they
encouraged by the resulting cooperation. However, in the case of know their rights.
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to Developers that use the GNU GPL protect your rights with two steps:
ensure that, in such cases, the modified source code becomes available (1) assert copyright on the software, and (2) offer you this License
to the community. It requires the operator of a network server to giving you legal permission to copy, distribute and/or modify it.
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and For the developers' and authors' protection, the GPL clearly explains
published by Affero, was designed to accomplish similar goals. This is that there is no warranty for this free software. For both users' and
a different license, not a version of the Affero GPL, but Affero has authors' sake, the GPL requires that modified versions be marked as
released a new version of the Affero GPL which permits relicensing under changed, so that their problems will not be attributed erroneously to
this license. authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@@ -60,7 +72,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License. "This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License. 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version but the special requirements of the GNU Affero General Public License,
3 of the GNU General Public License. section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions the GNU General Public License from time to time. Such new versions will
will be similar in spirit to the present version, but may differ in detail to be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published GNU General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published it under the terms of the GNU General Public License as published by
by the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer If the program does terminal interaction, make it output a short
network, you should also make sure that it provides a way for users to notice like this when it starts in an interactive mode:
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive <program> Copyright (C) <year> <name of author>
of the code. There are many ways you could offer source, and different This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
solutions will be better for different programs; see section 13 for the This is free software, and you are welcome to redistribute it
specific requirements. under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+2 -2
View File
@@ -5,7 +5,7 @@
# this is typically a path given in POSIX (e.g. forward slashes) # this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this # format, relative to the token %(here)s which refers to the location of this
# ini file # ini file
script_location = %(here)s/alembic script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time # Uncomment the line below if you want the files to be prepended with date and time
@@ -84,7 +84,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
sqlalchemy.url = sqlite:///./data/database.db # sqlalchemy.url is set dynamically from DATABASE_URL environment variable
[post_write_hooks] [post_write_hooks]
+1 -1
View File
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here # add your model's MetaData object here
# for 'autogenerate' support # for 'autogenerate' support
from models import Base from backend.shared.models import Base
target_metadata = Base.metadata target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
+107 -92
View File
@@ -1,103 +1,43 @@
import asyncio import asyncio
import time import time
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import subprocess import subprocess
import sys import sys
import os import os
from routes import account, messaging, profile, push, webrtc, devices, moderation, download import httpx
import logging import logging
from models import User # Gateway doesn't need direct model access - it's a stateless proxy
from constants import OWNER_USERNAME # Gateway doesn't need constants - it's a stateless proxy
from utils import get_client_ip from backend.shared.utils import get_client_ip
from db import POOL_CONFIG, SessionLocal # Gateway doesn't need database access - it's a stateless proxy
from logging_config import access_logger # noqa: F401 - ensure loggers configured from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
from security.audit import log_access from backend.security.audit import log_access
from security.rate_limit import limiter from backend.security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware from slowapi.middleware import SlowAPIMiddleware
# Service URL mapping for routing
SERVICE_URLS = {
"account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"),
"profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"),
"devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"),
"messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"),
"push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"),
"webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"),
"moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"),
}
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup - run migration in subprocess to avoid logging interference # Gateway is a stateless proxy - no database operations or background tasks needed
try: logger.info("Gateway proxy service initialized - routing to microservices")
logger.info("Starting database migration check...")
# Run migration in a separate process
subprocess.run(
[
sys.executable,
"-c",
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
],
cwd=os.path.dirname(os.path.abspath(__file__))
)
except Exception as e:
logger.error(f"Failed to run database migrations: {e}")
raise
try:
with SessionLocal() as db:
owner = db.query(User).filter(User.id == 1).first()
if owner and not owner.verified:
owner.verified = True
db.commit()
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
elif owner and owner.verified:
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
else:
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
except Exception as e:
logger.error(f"Failed to ensure owner verification: {e}")
logger.info(
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
POOL_CONFIG["pool_size"],
POOL_CONFIG["max_overflow"],
POOL_CONFIG["pool_timeout"],
POOL_CONFIG["pool_recycle"],
POOL_CONFIG["pool_pre_ping"],
)
# Start the messaging cleanup task
try:
from routes.messaging import messagingManager
messagingManager.start_cleanup_task()
logger.info("Messaging cleanup task started")
except Exception as e:
logger.error(f"Failed to start messaging cleanup task: {e}")
# Reset all rate limits on startup to ensure clean state
# This prevents rate limits from persisting across restarts
try:
from security.rate_limit import reset_all_rate_limits
cleared = reset_all_rate_limits()
if cleared > 0:
logger.info(f"Cleared {cleared} rate limit entries on startup")
except Exception as e:
logger.warning(f"Failed to reset rate limits on startup: {e}")
# Start the rate limit cleanup task
try:
from security.rate_limit import start_rate_limit_cleanup_task
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
logger.info("Rate limit cleanup task started")
except Exception as e:
logger.error(f"Failed to start rate limit cleanup task: {e}")
cleanup_task = None
yield yield
logger.info("Gateway proxy service shutting down.")
# Shutdown - cancel cleanup task if it exists
if cleanup_task:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Инициализация FastAPI # Инициализация FastAPI
app = FastAPI(title="FromChat", lifespan=lifespan) app = FastAPI(title="FromChat", lifespan=lifespan)
@@ -168,12 +108,87 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Routes # Common API endpoints - route to appropriate services (defined first for priority)
app.include_router(account.router) @app.api_route("/login", methods=["POST"])
app.include_router(messaging.router) async def login(request: Request):
app.include_router(profile.router) """Login endpoint - routes to account service."""
app.include_router(push.router, prefix="/push") return await _proxy_to_service("account", "login", request)
app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices") @app.api_route("/register", methods=["POST"])
app.include_router(moderation.router) async def register(request: Request):
app.include_router(download.router) """Register endpoint - routes to account service."""
return await _proxy_to_service("account", "register", request)
# API routes - route to appropriate microservices
@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_account(path: str, request: Request):
"""Proxy account service requests."""
return await _proxy_to_service("account", path, request)
@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_profile(path: str, request: Request):
"""Proxy profile service requests."""
return await _proxy_to_service("profile", path, request)
@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_devices(path: str, request: Request):
"""Proxy device service requests."""
return await _proxy_to_service("devices", path, request)
@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_messaging(path: str, request: Request):
"""Proxy messaging service requests."""
return await _proxy_to_service("messaging", path, request)
@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_push(path: str, request: Request):
"""Proxy push service requests."""
return await _proxy_to_service("push", path, request)
@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_webrtc(path: str, request: Request):
"""Proxy WebRTC service requests."""
return await _proxy_to_service("webrtc", path, request)
@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def proxy_moderation(path: str, request: Request):
"""Proxy moderation service requests."""
return await _proxy_to_service("moderation", path, request)
async def _proxy_to_service(service: str, path: str, request: Request):
"""Helper function to proxy requests to microservices."""
from fastapi.responses import Response
service_url = SERVICE_URLS[service]
target_url = f"{service_url}/{service}/{path}"
# Get request body
body = await request.body()
# Prepare headers (remove host header)
headers = dict(request.headers)
headers.pop("host", None)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
params=request.query_params,
)
# Return response with the same status code and content
content = response.content
return Response(
content=content,
status_code=response.status_code,
headers={"content-type": response.headers.get("content-type", "application/json")}
)
except httpx.RequestError as exc:
logging.error(f"Error communicating with {service} service: {exc}")
raise HTTPException(status_code=503, detail=f"Service {service} unavailable")
# Routes are handled by the catch-all proxy above
+6 -6
View File
@@ -1,7 +1,7 @@
from constants import * from backend.shared.constants import *
from db import * from backend.shared.db import *
from models import * from backend.shared.models import *
from validation import * from backend.shared.validation import *
from utils import * from backend.shared.utils import *
from dependencies import * from backend.shared.dependencies import *
from app import * from app import *
+29 -10
View File
@@ -8,7 +8,7 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from alembic.runtime.migration import MigrationContext from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine from sqlalchemy import create_engine
from constants import DATABASE_URL from backend.shared.constants import DATABASE_URL
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -19,6 +19,9 @@ def run_migrations():
This function will upgrade the database to the latest migration. This function will upgrade the database to the latest migration.
Fully automated - handles all scenarios automatically. Fully automated - handles all scenarios automatically.
""" """
# Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__))
try: try:
# FIRST: Check if database has any application tables (excluding alembic_version) # FIRST: Check if database has any application tables (excluding alembic_version)
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
@@ -31,12 +34,11 @@ def run_migrations():
# If no application tables exist, create them directly from models # If no application tables exist, create them directly from models
if not existing_tables: if not existing_tables:
logger.info("No application tables found. Creating all tables directly from models...") logger.info("No application tables found. Creating all tables directly from models...")
from models import Base from backend.shared.models import Base
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
logger.info("All tables created successfully from models.") logger.info("All tables created successfully from models.")
# Get the directory where this script is located # Note: Problematic migrations are now cleaned up during Docker build
current_dir = os.path.dirname(os.path.abspath(__file__))
# Create Alembic configuration # Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
@@ -55,6 +57,13 @@ def run_migrations():
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')] migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
# If no migration files exist after cleanup, create initial migration
if not migration_files:
logger.info("No migration files found after cleanup. Creating initial migration...")
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
logger.info(f"Created {len(migration_files)} initial migration(s)")
if not migration_files: if not migration_files:
logger.info("No migration files found. Creating initial migration...") logger.info("No migration files found. Creating initial migration...")
# Check if database exists and has tables # Check if database exists and has tables
@@ -138,7 +147,11 @@ def run_migrations():
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully.") logger.info("Database migrations completed successfully.")
except Exception as upgrade_error: except Exception as upgrade_error:
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error): error_msg = str(upgrade_error)
# Handle PostgreSQL "already exists" errors gracefully
if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower():
pass
elif "Can't locate revision identified by 'direct_creation'" in error_msg:
logger.info("Found 'direct_creation' revision - resetting migration state...") logger.info("Found 'direct_creation' revision - resetting migration state...")
# Clear the alembic_version table and start fresh # Clear the alembic_version table and start fresh
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
@@ -183,6 +196,7 @@ def run_migrations():
# Try upgrade again # Try upgrade again
command.upgrade(alembic_cfg, "head") command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully after reset.") logger.info("Database migrations completed successfully after reset.")
# Note: Index-related errors are now prevented by Docker build cleanup
else: else:
raise upgrade_error raise upgrade_error
@@ -289,7 +303,7 @@ def _populate_migration_file(migration_path):
def _generate_migration_from_models(): def _generate_migration_from_models():
"""Generate migration content dynamically from SQLAlchemy models.""" """Generate migration content dynamically from SQLAlchemy models."""
from models import Base from backend.shared.models import Base
import sqlalchemy as sa import sqlalchemy as sa
from datetime import datetime from datetime import datetime
@@ -474,10 +488,12 @@ def _get_column_type(column):
def _create_database_directly(): def _create_database_directly():
"""Fallback method: create database directly using SQLAlchemy.""" """Fallback method: create database directly using SQLAlchemy."""
from models import Base from backend.shared.models import Base
from db import engine from backend.shared.db import get_engine
from sqlalchemy import text, inspect from sqlalchemy import text, inspect
engine = get_engine()
# Check existing tables and update schema # Check existing tables and update schema
with engine.connect() as connection: with engine.connect() as connection:
inspector = inspect(connection) inspector = inspect(connection)
@@ -533,6 +549,9 @@ def _create_database_directly():
# Table doesn't exist, create it # Table doesn't exist, create it
logger.info(f"Creating table {table_name}") logger.info(f"Creating table {table_name}")
# Create alembic_version table manually
engine = get_engine(DATABASE_URL)
with engine.connect() as connection:
# Create alembic_version table manually # Create alembic_version table manually
connection.execute(text(""" connection.execute(text("""
CREATE TABLE IF NOT EXISTS alembic_version ( CREATE TABLE IF NOT EXISTS alembic_version (
@@ -556,9 +575,9 @@ def _create_database_directly():
revision_match = re.search(r"revision: str = '([^']+)'", content) revision_match = re.search(r"revision: str = '([^']+)'", content)
if revision_match: if revision_match:
revision_id = revision_match.group(1) revision_id = revision_match.group(1)
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')")) connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
else: else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
else: else:
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')")) connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
+2
View File
@@ -2,6 +2,7 @@ PyJWT>=2.8.0
fastapi[standard]>=0.116.1 fastapi[standard]>=0.116.1
pydantic>=2.11.7 pydantic>=2.11.7
sqlalchemy>=2.0.43 sqlalchemy>=2.0.43
psycopg2-binary>=2.9.9
bcrypt>=4.3.0 bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
@@ -15,3 +16,4 @@ httpx>=0.27.2
rich>=13.9.4 rich>=13.9.4
slowapi>=0.1.9 slowapi>=0.1.9
firebase_admin>=7.1.0 firebase_admin>=7.1.0
PyNaCl>=1.5.0
+53 -88
View File
@@ -8,16 +8,16 @@ import uuid
from user_agents import parse as parse_ua from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
from utils import create_token, get_password_hash, verify_password, get_client_ip from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
from validation import is_valid_password, is_valid_username, is_valid_display_name from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
import os import os
from security.audit import log_security from backend.security.audit import log_security
from security.profanity import contains_profanity from backend.security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
_FAILED_ATTEMPT_WINDOW_SECONDS = 300 _FAILED_ATTEMPT_WINDOW_SECONDS = 300
@@ -47,16 +47,16 @@ def convert_user(user: User) -> dict:
"id": user.id, "id": user.id,
"created_at": user.created_at.isoformat(), "created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(), "last_seen": user.last_seen.isoformat(),
"online": user.online, "online": user.is_online,
"username": user.username, "username": user.username,
"display_name": user.display_name, "display_name": user.display_name,
"profile_picture": user.profile_picture, "profile_picture": user.avatar_url,
"bio": user.bio, "bio": user.bio,
"admin": _is_admin(user), "admin": _is_admin(user),
"verified": user.verified, "verified": user.verified,
"suspended": user.suspended or False, "suspended": user.suspended,
"suspension_reason": user.suspension_reason, "suspension_reason": user.suspension_reason,
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted "deleted": user.deleted
} }
@router.get("/check_auth") @router.get("/check_auth")
@@ -77,7 +77,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(login_request.password.strip(), user.password_hash): if not user or not verify_password(login_request.password.strip(), user.hashed_password):
log_security( log_security(
"login_failed", "login_failed",
severity="warning", severity="warning",
@@ -112,30 +112,9 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
detail="Неверное имя пользователя или пароль" detail="Неверное имя пользователя или пароль"
) )
# Create device session and embed into JWT # Generate session ID for JWT (device session will be created on first device service access)
raw_ua = request.headers.get("user-agent")
device_name = request.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
user.online = True user.online = True
user.last_seen = datetime.now() user.last_seen = datetime.now()
db.commit() db.commit()
@@ -148,15 +127,19 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
for identifier in identifiers: for identifier in identifiers:
_reset_failed_logins(identifier) _reset_failed_logins(identifier)
# Parse user agent for logging
ua = parse_ua(raw_ua or "")
device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"
log_security( log_security(
"login_success", "login_success",
username=user.username, username=user.username,
user_id=user.id, user_id=user.id,
ip=client_ip, ip=client_ip,
session_id=session_id, session_id=session_id,
device=device.device_type, device=device_type,
os=device.os_name, os=ua.os.family,
browser=device.browser_name, browser=ua.browser.family,
) )
return { return {
@@ -230,8 +213,9 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
new_user = User( new_user = User(
username=username, username=username,
display_name=display_name, display_name=display_name,
password_hash=hashed_password, hashed_password=hashed_password,
online=True, salt="", # Not used since bcrypt includes salt in hash
is_online=True,
last_seen=datetime.now(), last_seen=datetime.now(),
verified=is_owner verified=is_owner
) )
@@ -240,32 +224,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
db.commit() db.commit()
db.refresh(new_user) db.refresh(new_user)
# Create initial device session # Generate a temporary session ID for the token (device session will be created on first device service access)
raw_ua = request.headers.get("user-agent")
device_name = request.headers.get("x-device-name")
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=new_user.id,
raw_user_agent=raw_ua,
device_name=device_name,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
db.commit()
token = create_token(new_user.id, new_user.username, session_id) token = create_token(new_user.id, new_user.username, session_id)
# Parse user agent for logging
raw_ua = request.headers.get("user-agent")
ua = parse_ua(raw_ua or "")
os_name = ua.os.family or "Unknown OS" os_name = ua.os.family or "Unknown OS"
if ua.os.version_string: if ua.os.version_string:
os_name = f"{os_name} {ua.os.version_string}" os_name = f"{os_name} {ua.os.version_string}"
@@ -356,7 +321,7 @@ def delete_user_as_owner(
raise HTTPException(status_code=400, detail="Cannot delete owner account") raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints # Manually delete user's messages to satisfy FK constraints
from models import Message # local import to avoid circular from backend.shared.models import Message # local import to avoid circular
db.query(Message).filter(Message.user_id == user.id).delete() db.query(Message).filter(Message.user_id == user.id).delete()
db.delete(user) db.delete(user)
@@ -380,14 +345,14 @@ def logout(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Revoke current session # Revoke current session - TODO: Move to device service
from utils import verify_token as _verify_token # from backend.shared.utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials) # payload = _verify_token(credentials.credentials)
if payload and payload.get("session_id"): # if payload and payload.get("session_id"):
db.query(DeviceSession).filter( # db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id, # DeviceSession.user_id == current_user.id,
DeviceSession.session_id == payload["session_id"], # DeviceSession.session_id == payload["session_id"],
).update({DeviceSession.revoked: True}) # ).update({DeviceSession.revoked: True})
current_user.online = False current_user.online = False
current_user.last_seen = datetime.now() current_user.last_seen = datetime.now()
@@ -399,7 +364,7 @@ def logout(
username=current_user.username, username=current_user.username,
user_id=current_user.id, user_id=current_user.id,
ip=client_ip, ip=client_ip,
session_id=payload.get("session_id") if payload else None, session_id=None, # TODO: Get session_id from device service
) )
return { return {
@@ -418,25 +383,25 @@ def change_password(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Verify current derived password against stored hash # Verify current derived password against stored hash
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash): if not verify_password(password_request.currentPasswordDerived.strip(), current_user.hashed_password):
raise HTTPException(status_code=401, detail="Текущий пароль неверный") raise HTTPException(status_code=401, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password # Update password hash to hash of new derived password
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip()) current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip())
db.commit() db.commit()
# Optionally revoke all other sessions, keeping the current one # Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
if password_request.logoutAllExceptCurrent: # if password_request.logoutAllExceptCurrent:
from utils import verify_token as _verify_token # from backend.shared.utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials) # payload = _verify_token(credentials.credentials)
if not payload: # if not payload:
raise HTTPException(status_code=401, detail="Invalid token") # raise HTTPException(status_code=401, detail="Invalid token")
current_session_id = payload.get("session_id") # current_session_id = payload.get("session_id")
db.query(DeviceSession).filter( # db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id, # DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id, # DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True}) # ).update({DeviceSession.revoked: True})
db.commit() # db.commit()
client_ip = get_client_ip(request) client_ip = get_client_ip(request)
log_security( log_security(
@@ -496,7 +461,7 @@ async def _delete_user_data(user: User, db: Session):
user.deleted = True user.deleted = True
user.display_name = f"Deleted User #{user_id}" user.display_name = f"Deleted User #{user_id}"
user.bio = None user.bio = None
user.password_hash = "" user.hashed_password = ""
user.username = f"deleted_{user_id}" user.username = f"deleted_{user_id}"
user.profile_picture = None user.profile_picture = None
user.last_seen = None # Clear last seen timestamp user.last_seen = None # Clear last seen timestamp
+3 -3
View File
@@ -2,9 +2,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from models import User, DeviceSession from backend.shared.models import User, DeviceSession
from utils import verify_token from backend.shared.utils import verify_token
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
router = APIRouter() router = APIRouter()
-381
View File
@@ -1,381 +0,0 @@
"""
Download routes for FromChat desktop and mobile builds.
Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching.
"""
import asyncio
import logging
import os
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/download", tags=["download"])
GITHUB_API = "https://api.github.com"
WEB_OWNER, WEB_REPO = "fromchat-messenger", "web"
APP_OWNER, APP_REPO = "fromchat-messenger", "app"
WORKFLOW_FILE = "build.yml"
TIMEOUT = 10.0
ARTIFACT_NAMES = {
"windows": "FromChat-windows",
"linux": "FromChat-linux",
"macos": "FromChat-macOS",
}
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def _headers() -> dict[str, str]:
token = os.environ.get("RELEASES_TOKEN")
if not token:
raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured")
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _etag_path(os_name: str) -> Path:
return CACHE_DIR / f"{os_name}.etag"
def _cached_file_path(os_name: str) -> Path:
ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa")
return CACHE_DIR / f"{os_name}{ext}"
async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]:
"""Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id)."""
artifact_name = ARTIFACT_NAMES[os_name]
logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE)
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url)
runs_resp = await client.get(
runs_url,
headers=_headers(),
params={"per_page": 1, "status": "success"},
)
logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code)
runs_resp.raise_for_status()
runs = runs_resp.json()
workflow_runs = runs.get("workflow_runs", [])
if not workflow_runs:
logger.warning("[download] No successful workflow runs for %s", artifact_name)
raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}")
run_id = workflow_runs[0]["id"]
logger.info("[download] Latest run_id=%s, fetching artifacts", run_id)
artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts"
artifacts_resp = await client.get(artifacts_url, headers=_headers())
logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code)
artifacts_resp.raise_for_status()
data = artifacts_resp.json()
for artifact in data.get("artifacts", []):
if artifact["name"] == artifact_name:
url = artifact["archive_download_url"]
aid = artifact["id"]
logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url)
return url, aid
logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id)
raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found")
async def _fetch_mobile_asset_url(os_name: str) -> str:
"""Fetch latest release, find asset by name. Returns browser_download_url."""
keyword = "android" if os_name == "android" else "ios"
logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO)
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases"
logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url)
resp = await client.get(
releases_url,
headers=_headers(),
params={"per_page": 10},
)
logger.info("[download] GitHub releases response: status=%s", resp.status_code)
resp.raise_for_status()
releases = resp.json()
for release in releases:
if release.get("draft"):
continue
for asset in release.get("assets", []):
if keyword.lower() in asset.get("name", "").lower():
url = asset["browser_download_url"]
logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name"))
return url
logger.warning("[download] No %s asset in releases", os_name)
raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases")
async def _download_and_stream(
url: str,
os_name: str,
stored_etag: str | None,
) -> StreamingResponse | FileResponse:
"""Stream from GitHub to client and save to disk. If 304, serve from disk."""
etag_path = _etag_path(os_name)
cache_path = _cached_file_path(os_name)
cache_path.parent.mkdir(parents=True, exist_ok=True)
headers = {**_headers(), "Accept": "*/*"}
if stored_etag:
headers["If-None-Match"] = stored_etag
logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none")
async def stream_and_save():
total = 0
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
new_etag: str | None = None
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code == 304 and cache_path.exists():
yield None
return
if resp.status_code != 200:
if resp.status_code in (404, 410):
raise HTTPException(
status_code=404,
detail="Release asset not found on GitHub",
)
raise HTTPException(
status_code=503,
detail="GitHub returned an error while downloading asset",
)
new_etag = resp.headers.get("etag")
logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
with open(tmp_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
total += len(chunk)
yield chunk
tmp_path.rename(cache_path)
if new_etag:
etag_path.write_text(new_etag)
logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total)
except httpx.StreamClosed:
logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total)
tmp_path.unlink(missing_ok=True)
except httpx.TimeoutException:
tmp_path.unlink(missing_ok=True)
if cache_path.exists():
raise _CacheFallback()
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
class _CacheFallback(Exception):
pass
gen = stream_and_save()
try:
first = await gen.__anext__()
except StopAsyncIteration:
first = None
except _CacheFallback:
await gen.aclose()
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
if first is None:
await gen.aclose()
logger.info("[download] Mobile %s: serving from cache (304)", os_name)
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
async def body():
yield first
async for chunk in gen:
yield chunk
return StreamingResponse(
body(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
)
async def _resolve_artifact_download_url(url: str) -> str:
"""Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following."""
headers = {**_headers(), "Accept": "application/vnd.github+json"}
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
resp = await client.get(url, headers=headers)
if resp.status_code in (404, 410):
raise HTTPException(status_code=404, detail="Artifact not found on GitHub")
if resp.status_code != 302:
raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL")
location = resp.headers.get("location")
if not location:
raise HTTPException(status_code=502, detail="No redirect location from GitHub")
return location
async def _download_artifact_and_stream(
url: str,
os_name: str,
artifact_id: int,
) -> StreamingResponse | FileResponse:
"""Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization."""
etag_path = _etag_path(os_name)
cache_path = _cached_file_path(os_name)
stored_id = etag_path.read_text().strip() if etag_path.exists() else None
if stored_id == str(artifact_id) and cache_path.exists():
logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id)
return FileResponse(
str(cache_path),
media_type="application/zip",
filename=cache_path.name,
)
try:
download_url = await _resolve_artifact_download_url(url)
except HTTPException:
if cache_path.exists():
logger.info("[download] PC %s: GitHub error, serving from cache", os_name)
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
raise
logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name)
async def stream_and_save():
total = 0
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
async with client.stream("GET", download_url) as resp:
if resp.status_code != 200:
if resp.status_code in (404, 410):
raise HTTPException(status_code=404, detail="Artifact file not found on GitHub")
raise HTTPException(
status_code=503,
detail="GitHub returned an error while downloading artifact file",
)
logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
with open(tmp_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
total += len(chunk)
yield chunk
tmp_path.rename(cache_path)
etag_path.write_text(str(artifact_id))
logger.info("[download] PC %s: completed, saved %d bytes", os_name, total)
except httpx.StreamClosed:
logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total)
tmp_path.unlink(missing_ok=True)
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
gen = stream_and_save()
try:
first = await gen.__anext__()
except StopAsyncIteration:
first = None
except HTTPException:
if cache_path.exists():
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
raise
if first is None:
await gen.aclose()
raise HTTPException(status_code=502, detail="Empty response from download")
async def body():
yield first
async for chunk in gen:
yield chunk
return StreamingResponse(
body(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
)
def _head_response(filename: str, content_length: int | None = None) -> Response:
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return Response(status_code=200, headers=headers)
@router.api_route("/{os_name}", methods=["GET", "HEAD"])
async def download(request: Request, os_name: str):
"""Download app for the given OS: windows, linux, macos, android, ios."""
is_head = request.method == "HEAD"
os_name = os_name.lower()
logger.info("[download] %s /download/%s", request.method, os_name)
if os_name not in ("windows", "linux", "macos", "android", "ios"):
raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios")
try:
if os_name in ARTIFACT_NAMES:
try:
url, artifact_id = await asyncio.wait_for(
_fetch_pc_artifact_url(os_name),
timeout=TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("[download] PC %s: GitHub API timeout", os_name)
cache_path = _cached_file_path(os_name)
if cache_path.exists():
if is_head:
return _head_response(cache_path.name, cache_path.stat().st_size)
return FileResponse(
str(cache_path),
media_type="application/zip",
filename=cache_path.name,
)
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
cache_path = _cached_file_path(os_name)
result = await _download_artifact_and_stream(url, os_name, artifact_id)
if is_head:
fn = getattr(result, "filename", None) or cache_path.name
size = cache_path.stat().st_size if cache_path.exists() else None
return _head_response(fn, size)
return result
else:
stored_etag = None
etag_path = _etag_path(os_name)
cache_path = _cached_file_path(os_name)
if etag_path.exists():
stored_etag = etag_path.read_text().strip() or None
try:
url = await asyncio.wait_for(
_fetch_mobile_asset_url(os_name),
timeout=TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("[download] Mobile %s: GitHub API timeout", os_name)
if cache_path.exists():
if is_head:
return _head_response(cache_path.name, cache_path.stat().st_size)
return FileResponse(
str(cache_path),
media_type="application/octet-stream",
filename=cache_path.name,
)
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
result = await _download_and_stream(url, os_name, stored_etag)
if is_head:
fn = getattr(result, "filename", None) or cache_path.name
size = cache_path.stat().st_size if cache_path.exists() else None
return _head_response(fn, size)
return result
except HTTPException as exc:
if exc.status_code in (404, 410):
raise HTTPException(status_code=404, detail=exc.detail)
if exc.status_code in (502, 503, 504):
raise HTTPException(status_code=503, detail=exc.detail)
raise
+41 -13
View File
@@ -16,29 +16,30 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from backend.shared.dependencies import get_current_user, get_db
from .account import convert_user from backend.shared.utils import convert_user
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
from push_service import push_service import os
import httpx
from PIL import Image from PIL import Image
import io import io
import json import json
from pydantic import BaseModel from pydantic import BaseModel
from better_profanity import profanity as _bp from better_profanity import profanity as _bp
from security.audit import log_access, log_dm, log_public_chat, log_security from backend.security.audit import log_access, log_dm, log_public_chat, log_security
from security.profanity import contains_profanity from backend.security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from backend.security.rate_limit import rate_limit_per_ip
from websocket.utils import authenticate_user from backend.services.messaging.files.websocket.utils import authenticate_user
from models import FcmToken from backend.shared.models import FcmToken
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
FILES_BASE_DIR = Path("data/uploads/files") FILES_BASE_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "files"
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal" FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
@@ -393,7 +394,16 @@ async def _send_message_internal(
# Send push notifications for public messages # Send push notifications for public messages
try: try:
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id) push_service_url = os.getenv("PUSH_SERVICE_URL", "http://push_service:8306")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{push_service_url}/push/send-public-notification",
json={
"message_id": new_message.id,
"exclude_user_id": current_user.id
}
)
response.raise_for_status()
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {e}") logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
@@ -1282,7 +1292,7 @@ class MessaggingSocketManager:
self.ws_subscriptions[websocket] = set() self.ws_subscriptions[websocket] = set()
# Import here to avoid circular import # Import here to avoid circular import
from websocket.handlers import handler_registry from backend.services.messaging.files.websocket.handlers import handler_registry
while True: while True:
try: try:
@@ -1561,3 +1571,21 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur
raise HTTPException(500) raise HTTPException(500)
return FileResponse(str(path)) return FileResponse(str(path))
class SendSuspensionRequest(BaseModel):
user_id: int
reason: str
@router.post("/send-suspension")
async def send_suspension_to_user(
request: SendSuspensionRequest,
db: Session = Depends(get_db)
):
"""Send suspension message to user via WebSocket (called by profile service)"""
try:
await messagingManager.send_suspension_to_user(request.user_id, request.reason)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+6 -6
View File
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List from typing import List
from constants import OWNER_USERNAME from backend.shared.constants import OWNER_USERNAME
from dependencies import get_current_user from backend.shared.dependencies import get_current_user
from models import User from backend.shared.models import User
from security.audit import log_security from backend.security.audit import log_security
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
class BlocklistUpdateRequest(BaseModel): class BlocklistUpdateRequest(BaseModel):
+20 -10
View File
@@ -9,15 +9,16 @@ import uuid
import io import io
from fastapi import Request from fastapi import Request
from dependencies import get_db, get_current_user from backend.shared.dependencies import get_db, get_current_user
from models import User, UpdateBioRequest, UserProfileResponse from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
from pydantic import BaseModel from pydantic import BaseModel
from validation import is_valid_username, is_valid_display_name from backend.shared.validation import is_valid_username, is_valid_display_name
from similarity import is_user_similar_to_verified from backend.shared.similarity import is_user_similar_to_verified
from .messaging import messagingManager import os
from security.audit import log_security import httpx
from security.profanity import contains_profanity from backend.security.audit import log_security
from security.rate_limit import rate_limit_per_ip from backend.security.profanity import contains_profanity
from backend.security.rate_limit import rate_limit_per_ip
router = APIRouter() router = APIRouter()
@@ -36,7 +37,7 @@ class UpdateProfileRequest(BaseModel):
description: str | None = None description: str | None = None
# Create uploads directory if it doesn't exist # Create uploads directory if it doesn't exist
PROFILE_PICTURES_DIR = Path("data/uploads/pfp") PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp"
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
@@ -479,7 +480,16 @@ async def suspend_user(
# Send WebSocket suspension message # Send WebSocket suspension message
try: try:
await messagingManager.send_suspension_to_user(user_id, request.reason) messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{messaging_service_url}/messaging/send-suspension",
json={
"user_id": user_id,
"reason": request.reason
}
)
response.raise_for_status()
except Exception as e: except Exception as e:
# Log error but don't fail the request # Log error but don't fail the request
pass pass
+26 -3
View File
@@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from pydantic import BaseModel
from models import User, PushSubscriptionRequest from backend.shared.dependencies import get_current_user, get_db
from push_service import push_service from backend.shared.models import User, PushSubscriptionRequest
from backend.services.push.files import push_service
router = APIRouter() router = APIRouter()
class SendPublicNotificationRequest(BaseModel):
message_id: int
exclude_user_id: int
@router.post("/subscribe") @router.post("/subscribe")
async def subscribe_to_push_notifications( async def subscribe_to_push_notifications(
request: PushSubscriptionRequest, request: PushSubscriptionRequest,
@@ -44,3 +49,21 @@ async def unsubscribe_from_push_notifications(
raise HTTPException(status_code=500, detail="Failed to disable push notifications") raise HTTPException(status_code=500, detail="Failed to disable push notifications")
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/send-public-notification")
async def send_public_message_notification(
request: SendPublicNotificationRequest,
db: Session = Depends(get_db)
):
"""Send push notification for public message (called by messaging service)"""
try:
# Get the message from database
from backend.shared.models import Message
message = db.query(Message).filter(Message.id == request.message_id).first()
if not message:
raise HTTPException(status_code=404, detail="Message not found")
await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id)
return {"status": "success"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+1 -1
View File
@@ -4,7 +4,7 @@ import hmac
import hashlib import hashlib
import time import time
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from dependencies import get_current_user from backend.shared.dependencies import get_current_user
import traceback import traceback
router = APIRouter() router = APIRouter()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Local development server that runs all services in a single FastAPI application.
This provides the same monolithic experience as before, but with microservice separation.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
import os
# Import service routers
from backend.routes.account import router as account_router
from backend.routes.profile import router as profile_router
from backend.routes.devices import router as device_router
from backend.routes.messaging import router as messaging_router
from backend.routes.push import router as push_router
from backend.routes.webrtc import router as webrtc_router
from backend.routes.moderation import router as moderation_router
# Import security modules
from security.audit import log_access
from security.rate_limit import limiter
from slowapi.middleware import SlowAPIMiddleware
# Create main FastAPI app
app = FastAPI(title="FromChat Local Development")
# Add rate limiting middleware
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://fromchat.ru",
"https://beta.fromchat.ru",
"https://www.fromchat.ru",
"http://127.0.0.1:8301",
"http://127.0.0.1:8300",
"http://localhost:8301",
"http://localhost:8300",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount service routers with appropriate prefixes
app.include_router(account_router, prefix="/account")
app.include_router(profile_router, prefix="/profile")
app.include_router(device_router, prefix="/devices")
app.include_router(messaging_router, prefix="/messaging")
app.include_router(push_router, prefix="/push")
app.include_router(webrtc_router, prefix="/webrtc")
app.include_router(moderation_router, prefix="/moderation")
if __name__ == "__main__":
# Run the server
port = int(os.getenv("PORT", "8301"))
host = os.getenv("HOST", "127.0.0.1")
print(f"Starting FromChat local development server on {host}:{port}")
print("Available services:")
print(" - Account: http://127.0.0.1:8301/account/")
print(" - Profile: http://127.0.0.1:8301/profile/")
print(" - Devices: http://127.0.0.1:8301/devices/")
print(" - Messaging: http://127.0.0.1:8301/messaging/")
print(" - Push: http://127.0.0.1:8301/push/")
print(" - WebRTC: http://127.0.0.1:8301/webrtc/")
print(" - Moderation: http://127.0.0.1:8301/moderation/")
uvicorn.run(
"run_local:app",
host=host,
port=port,
reload=True,
reload_dirs=["backend"]
)
+1 -1
View File
@@ -4,7 +4,7 @@ import logging
from html import unescape from html import unescape
from typing import Any, Callable, Dict, List from typing import Any, Callable, Dict, List
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger from backend.logging_config import access_logger, dm_logger, public_chat_logger, security_logger
def _clean_username(username: Any) -> str: def _clean_username(username: Any) -> str:
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
from better_profanity import Profanity from better_profanity import Profanity
BLOCKLIST_PATH = Path("data/profanity/blocklist.json") BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json"
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
_CUSTOM_RU_TERMS: Set[str] = { _CUSTOM_RU_TERMS: Set[str] = {
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi import Request
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from utils import get_client_ip from backend.shared.utils import get_client_ip
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
+11
View File
@@ -0,0 +1,11 @@
# Account Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/account.py /app/backend/routes/account.py
COPY backend/services/account/main.py /app/backend/services/account/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=account
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.account import router as account_router
if __name__ == "__main__":
app = FastAPI(title="Account Service")
app.include_router(account_router, prefix="/account")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+11
View File
@@ -0,0 +1,11 @@
# Device Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/devices.py /app/backend/routes/devices.py
COPY backend/services/device/main.py /app/backend/services/device/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=device
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.devices import router as device_router
if __name__ == "__main__":
app = FastAPI(title="Device Service")
app.include_router(device_router, prefix="/devices")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+14
View File
@@ -0,0 +1,14 @@
# Gateway Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/app.py /app/backend/app.py
COPY backend/main.py /app/backend/main.py
COPY backend/dependencies.py /app/backend/dependencies.py
COPY backend/security /app/backend/security/
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=gateway
EXPOSE 8301
+6
View File
@@ -0,0 +1,6 @@
# Gateway service - runs the main gateway app from backend/app.py
if __name__ == "__main__":
from backend.app import app
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300)))
+12
View File
@@ -0,0 +1,12 @@
# Messaging Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
COPY backend/websocket /app/backend/websocket/
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=messaging
EXPOSE 8301
@@ -1,4 +1,4 @@
from websocket.registry import WebSocketHandlerRegistry from .registry import WebSocketHandlerRegistry
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency # Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
# Import them directly from websocket.handlers when needed # Import them directly from websocket.handlers when needed
@@ -6,8 +6,8 @@ from typing import Any
from fastapi import HTTPException, WebSocket, Request from fastapi import HTTPException, WebSocket, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from websocket.registry import WebSocketHandlerRegistry from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
from routes.messaging import ( from backend.routes.messaging import (
MessaggingSocketManager, MessaggingSocketManager,
_send_message_internal, _send_message_internal,
_edit_message_internal, _edit_message_internal,
@@ -17,7 +17,7 @@ from routes.messaging import (
add_reaction, add_reaction,
add_dm_reaction, add_dm_reaction,
) )
from models import ( from backend.shared.models import (
User, User,
SendMessageRequest, SendMessageRequest,
EditMessageRequest, EditMessageRequest,
@@ -26,7 +26,7 @@ from models import (
DMReactionRequest, DMReactionRequest,
UpdateLog, UpdateLog,
) )
from security.audit import log_access, log_dm from backend.security.audit import log_access, log_dm
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -2,8 +2,8 @@ from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from types import SimpleNamespace from types import SimpleNamespace
from dependencies import get_current_user from backend.shared.dependencies import get_current_user
from models import User from backend.shared.models import User
def extract_token_from_data(data: dict) -> str | None: def extract_token_from_data(data: dict) -> str | None:
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.messaging import router as messaging_router
if __name__ == "__main__":
app = FastAPI(title="Messaging Service")
app.include_router(messaging_router, prefix="/messaging")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
@@ -0,0 +1,15 @@
# Migration Runner Dockerfile
FROM backend/base:latest
# Copy migration files
COPY backend/alembic /app/backend/alembic/
COPY backend/migration.py /app/backend/migration.py
# Copy migration runner
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=migration_runner
# Override entrypoint to run migrations
ENTRYPOINT ["python", "-m", "backend.services.migration_runner.main"]
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""
Migration runner service - executes database migrations and exits.
This service runs Alembic migrations against PostgreSQL and terminates.
"""
import os
import sys
from pathlib import Path
from alembic import command
from alembic.config import Config
def run_migrations():
"""Run Alembic migrations."""
print("Starting database migrations...")
# Change to backend directory to run migrations
backend_dir = Path(__file__).parent.parent.parent
os.chdir(backend_dir)
# Ensure shared models are imported for alembic
import backend.shared.models
# Set DATABASE_URL from environment if not set
db_url = os.getenv("DATABASE_URL")
if not db_url:
print("ERROR: DATABASE_URL environment variable not set")
sys.exit(1)
# Export DATABASE_URL for alembic
os.environ["DATABASE_URL"] = db_url
try:
# Use the robust migration system from migration.py
# This handles all edge cases and recovery scenarios automatically
print("Starting database migrations...")
# Import and run the migration function
from backend.migration import run_migrations
run_migrations()
print("Database migrations completed successfully!")
except Exception as e:
print(f"ERROR: Failed to run migrations: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
run_migrations()
+13
View File
@@ -0,0 +1,13 @@
# Moderation Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
COPY backend/security/profanity.py /app/backend/security/profanity.py
COPY backend/similarity.py /app/backend/similarity.py
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=moderation
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.moderation import router as moderation_router
if __name__ == "__main__":
app = FastAPI(title="Moderation Service")
app.include_router(moderation_router, prefix="/moderation")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+11
View File
@@ -0,0 +1,11 @@
# Profile Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/profile.py /app/backend/routes/profile.py
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=profile
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.profile import router as profile_router
if __name__ == "__main__":
app = FastAPI(title="Profile Service")
app.include_router(profile_router, prefix="/profile")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+12
View File
@@ -0,0 +1,12 @@
# Push Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/push.py /app/backend/routes/push.py
COPY backend/push_service.py /app/backend/push_service.py
COPY backend/services/push/main.py /app/backend/services/push/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=push
EXPOSE 8301
@@ -4,8 +4,7 @@ import os
from typing import List, Optional from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
from models import PushSubscription, User, Message, DMEnvelope from backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
from models import FcmToken
import firebase_admin import firebase_admin
from firebase_admin import credentials as firebase_credentials from firebase_admin import credentials as firebase_credentials
from firebase_admin import messaging as firebase_messaging from firebase_admin import messaging as firebase_messaging
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.push import router as push_router
if __name__ == "__main__":
app = FastAPI(title="Push Service")
app.include_router(push_router, prefix="/push")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+11
View File
@@ -0,0 +1,11 @@
# WebRTC Service Dockerfile
FROM backend/base:latest
# Copy service-specific files
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
# Set service name for entrypoint
ENV SERVICE_NAME=webrtc
EXPOSE 8301
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from backend.routes.webrtc import router as webrtc_router
if __name__ == "__main__":
app = FastAPI(title="WebRTC Service")
app.include_router(webrtc_router, prefix="/webrtc")
import os, uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
+1
View File
@@ -0,0 +1 @@
# Shared modules package
+50
View File
@@ -0,0 +1,50 @@
import os
# Database
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db")
# JWT
JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development")
JWT_ALGORITHM = "HS256"
# Token inactivity expiration - token expires if not used for this duration
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
# Owner user
OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner")
# Push notifications
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com")
# Rate limiting
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
# File uploads
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"}
# WebSocket
WEBSOCKET_PING_INTERVAL = 30
WEBSOCKET_PING_TIMEOUT = 60
# Encryption
ENCRYPTION_KEY_LENGTH = 32
ENCRYPTION_NONCE_LENGTH = 12
# Moderation
PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8"))
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85"))
# WebRTC
WEBRTC_ICE_SERVERS = [
{"urls": "stun:stun.l.google.com:19302"},
{"urls": "stun:stun1.l.google.com:19302"}
]
# Logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+62
View File
@@ -0,0 +1,62 @@
import os
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy import create_engine
from .constants import DATABASE_URL
# Database connection settings
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
POOL_CONFIG = {
"pool_size": POOL_SIZE,
"max_overflow": MAX_OVERFLOW,
"pool_recycle": POOL_RECYCLE,
"pool_timeout": POOL_TIMEOUT,
"pool_pre_ping": True,
}
def create_engine_from_url(database_url: str):
"""Create SQLAlchemy engine from database URL."""
connect_args = {}
if database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
engine_kwargs = {
"pool_size": POOL_SIZE,
"max_overflow": MAX_OVERFLOW,
"pool_recycle": POOL_RECYCLE,
"pool_pre_ping": True,
"pool_timeout": POOL_TIMEOUT,
}
engine = create_engine(
database_url,
connect_args=connect_args,
**engine_kwargs,
)
return engine
# Create engine - this should be called by each service with its own DATABASE_URL
def get_engine(database_url: str = None):
"""Get SQLAlchemy engine for the given database URL."""
url = database_url or DATABASE_URL
return create_engine_from_url(url)
# Session factory - create per service
def get_session_factory(database_url: str = None):
"""Get session factory for the given database URL."""
engine = get_engine(database_url)
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency for FastAPI - create per service
def get_db(database_url: str = None):
"""FastAPI dependency to get database session."""
SessionLocal = get_session_factory(database_url)
db = SessionLocal()
try:
yield db
finally:
db.close()
+122
View File
@@ -0,0 +1,122 @@
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from backend.shared.utils import verify_token
from backend.shared.models import User, DeviceSession
from backend.shared.db import get_session_factory
import logging
security = HTTPBearer()
logger = logging.getLogger("uvicorn.error")
# Зависимость для получения сессии БД
SessionLocal = get_session_factory()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Зависимость для получения текущего пользователя
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
try:
payload = verify_token(token)
except Exception as e:
logger.warning("get_current_user: token verification error: %s", str(e))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
if not payload:
logger.info("get_current_user: verify_token returned empty payload")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user:
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if user.id == 1 and user.suspended:
user.suspended = False
user.suspension_reason = None
db.commit()
db.refresh(user)
# Validate device session from JWT
session_id = payload.get("session_id")
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session",
headers={"WWW-Authenticate": "Bearer"},
)
device_session = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
.first()
)
if not device_session or device_session.revoked:
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if session has been inactive for too long (sliding expiration)
from backend.shared.constants import TOKEN_INACTIVITY_EXPIRE_HOURS
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
if device_session.last_seen < inactivity_threshold:
# Session expired due to inactivity - revoke it
device_session.revoked = True
db.commit()
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity",
headers={"WWW-Authenticate": "Bearer"},
)
# Touch last_seen on valid session (sliding expiration - extends token life)
device_session.last_seen = datetime.now()
db.commit()
# Check if user is suspended
if user.suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended",
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
)
# Check if user is deleted
if user.deleted:
logger.info("get_current_user: account deleted for user_id=%s", user.id)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted",
)
request.state.current_user = user
request.state.session_id = session_id
return user
+423
View File
@@ -0,0 +1,423 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
import json
from pydantic import BaseModel
Base = declarative_base()
class User(Base):
__tablename__ = "users"
__table_args__ = {"schema": "account_schema"}
id = Column(BigInteger, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
salt = Column(String(64), nullable=False)
display_name = Column(String(100), nullable=True)
bio = Column(Text, nullable=True)
avatar_url = Column(String(255), nullable=True)
is_online = Column(Boolean, default=False)
last_seen = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True)
reset_token = Column(String(255), nullable=True)
reset_token_expires = Column(DateTime, nullable=True)
two_factor_enabled = Column(Boolean, default=False)
two_factor_secret = Column(String(255), nullable=True)
login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
public_key = Column(Text, nullable=True)
private_key = Column(Text, nullable=True)
encryption_enabled = Column(Boolean, default=False)
suspended = Column(Boolean, default=False)
suspension_reason = Column(Text, nullable=True)
deleted = Column(Boolean, default=False)
# Relationships
messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan")
message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan")
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
class Message(Base):
__tablename__ = "messages"
__table_args__ = {"schema": "messaging_schema"}
id = Column(BigInteger, primary_key=True, index=True)
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
content = Column(Text, nullable=False)
content_type = Column(String(50), default="text")
encrypted_content = Column(Text, nullable=True)
signature = Column(Text, nullable=True)
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
edited_at = Column(DateTime, nullable=True)
edited = Column(Boolean, default=False)
deleted = Column(Boolean, default=False)
reply_to_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
thread_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
is_public = Column(Boolean, default=False)
# Relationships
sender = relationship("User", back_populates="messages")
recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan")
reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id])
thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id])
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan")
class MessageRecipient(Base):
__tablename__ = "message_recipients"
__table_args__ = {"schema": "messaging_schema"}
id = Column(BigInteger, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
read_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
encrypted_key = Column(Text, nullable=True)
# Relationships
message = relationship("Message", back_populates="recipients")
recipient = relationship("User", back_populates="message_recipients")
class MessageReaction(Base):
__tablename__ = "message_reactions"
__table_args__ = {"schema": "messaging_schema"}
id = Column(BigInteger, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
reaction = Column(String(50), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
message = relationship("Message", back_populates="reactions")
class Device(Base):
__tablename__ = "devices"
__table_args__ = {"schema": "device_schema"}
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
device_id = Column(String(255), unique=True, nullable=False, index=True)
device_name = Column(String(255), nullable=True)
device_type = Column(String(50), nullable=True)
public_key = Column(Text, nullable=True)
signed_prekey = Column(Text, nullable=True)
one_time_prekeys = Column(JSON, nullable=True)
last_active = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="devices")
push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan")
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
__table_args__ = {"schema": "push_schema"}
id = Column(BigInteger, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True)
endpoint = Column(String(500), nullable=False)
p256dh = Column(String(255), nullable=False)
auth = Column(String(255), nullable=False)
user_agent = Column(String(500), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="push_subscriptions")
device = relationship("Device", back_populates="push_subscriptions")
class WebRTCSession(Base):
__tablename__ = "webrtc_sessions"
__table_args__ = {"schema": "webrtc_schema"}
id = Column(BigInteger, primary_key=True, index=True)
session_id = Column(String(255), unique=True, nullable=False, index=True)
initiator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
participant_ids = Column(JSON, nullable=False)
offer = Column(JSON, nullable=True)
answer = Column(JSON, nullable=True)
ice_candidates = Column(JSON, nullable=True)
status = Column(String(50), default="pending")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ModerationAction(Base):
__tablename__ = "moderation_actions"
__table_args__ = {"schema": "moderation_schema"}
id = Column(BigInteger, primary_key=True, index=True)
moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True)
target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
action_type = Column(String(50), nullable=False)
reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True)
class MessageFile(Base):
__tablename__ = "message_file"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
path = Column(Text, nullable=False)
name = Column(Text, nullable=False)
message = relationship("Message", back_populates="files")
class CryptoPublicKey(Base):
__tablename__ = "crypto_public_key"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
public_key_b64 = Column(Text, nullable=False)
class CryptoBackup(Base):
__tablename__ = "crypto_backup"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
blob_json = Column(Text, nullable=False)
class DMEnvelope(Base):
__tablename__ = "dm_envelope"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
iv_b64 = Column(Text, nullable=False)
ciphertext_b64 = Column(Text, nullable=False)
salt_b64 = Column(Text, nullable=False)
iv2_b64 = Column(Text, nullable=False)
wrapped_mk_b64 = Column(Text, nullable=False)
reply_to_id = Column(Integer, nullable=True)
timestamp = Column(DateTime, default=datetime.now)
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
class DMFile(Base):
__tablename__ = "dm_file"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
name = Column(Text, nullable=False)
path = Column(Text, nullable=False)
message = relationship("DMEnvelope", back_populates="files")
class FcmToken(Base):
__tablename__ = "fcm_token"
__table_args__ = {"schema": "push_schema"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
token = Column(Text, nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base):
__tablename__ = "reaction"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
# Ensure unique combination of message, user, and emoji
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
class DMReaction(Base):
__tablename__ = "dm_reaction"
__table_args__ = {"schema": "messaging_schema"}
id = Column(Integer, primary_key=True, index=True)
dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
emoji = Column(String(10), nullable=False) # Store emoji as string
timestamp = Column(DateTime, default=datetime.now)
# Relationships
user = relationship("User")
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
# Ensure unique combination of dm_envelope, user, and emoji
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
# Tracks authenticated device sessions per user
class DeviceSession(Base):
__tablename__ = "device_session"
__table_args__ = {"schema": "device_schema"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
# Raw User-Agent for reference/debugging
raw_user_agent = Column(Text, nullable=True)
# Parsed fields
device_name = Column(String(128), nullable=True)
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
os_name = Column(String(64), nullable=True)
os_version = Column(String(64), nullable=True)
browser_name = Column(String(64), nullable=True)
browser_version = Column(String(64), nullable=True)
brand = Column(String(64), nullable=True)
model = Column(String(64), nullable=True)
# Session identity embedded into JWTs
session_id = Column(String(64), unique=True, nullable=False, index=True)
# Lifecycle
created_at = Column(DateTime, default=datetime.now)
last_seen = Column(DateTime, default=datetime.now)
revoked = Column(Boolean, default=False)
# Relationship back to user (optional lazy to avoid heavy loads)
user = relationship("User", lazy="select")
# Pydantic models
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str
display_name: str
password: str
confirm_password: str
class ChangePasswordRequest(BaseModel):
currentPasswordDerived: str
newPasswordDerived: str
logoutAllExceptCurrent: bool = False
class SendMessageRequest(BaseModel):
content: str
reply_to_id: int | None = None
class EditMessageRequest(BaseModel):
content: str
class DeleteMessageRequest(BaseModel):
message_id: int
class UpdateBioRequest(BaseModel):
bio: str
class PushSubscriptionRequest(BaseModel):
endpoint: str
keys: dict
class UserProfileResponse(BaseModel):
id: int
username: str
display_name: str
profile_picture: str | None
bio: str | None
online: bool
last_seen: datetime | None
created_at: datetime | None
verified: bool
suspended: bool
suspension_reason: str | None
deleted: bool
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: int
content: str
timestamp: datetime
is_author: bool
is_read: bool
username: str
profile_picture: str | None
class Config:
from_attributes = True
class ReactionRequest(BaseModel):
message_id: int
emoji: str
class ReactionResponse(BaseModel):
id: int
message_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
class DMReactionRequest(BaseModel):
dm_envelope_id: int
emoji: str
class DMReactionResponse(BaseModel):
id: int
dm_envelope_id: int
user_id: int
emoji: str
timestamp: datetime
username: str
class Config:
from_attributes = True
class UpdateLog(Base):
"""Stores update sequence numbers and updates for gap detection"""
__tablename__ = "update_log"
__table_args__ = {"schema": "public"}
id = Column(Integer, primary_key=True, index=True)
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
sequence = Column(Integer, nullable=False, index=True)
updates = Column(Text, nullable=False) # JSON array of updates
timestamp = Column(DateTime, default=datetime.now, index=True)
__table_args__ = (
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
)
+234
View File
@@ -0,0 +1,234 @@
import secrets
import string
import hashlib
import hmac
import base64
import json
from datetime import datetime, timedelta
from typing import Optional
import re
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
import nacl.secret
import nacl.utils
from fastapi import Request
import jwt
import bcrypt
from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS
import ipaddress
def generate_secure_token(length: int = 32) -> str:
"""Generate a cryptographically secure random token."""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
"""Hash a password with PBKDF2 and return (hash, salt)."""
if salt is None:
salt = secrets.token_bytes(32)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
key = kdf.derive(password.encode())
return base64.b64encode(key).decode(), salt
def verify_password(password: str, hashed: str, salt: bytes) -> bool:
"""Verify a password against its hash and salt."""
try:
key = base64.b64decode(hashed)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend()
)
kdf.verify(password.encode(), key)
return True
except:
return False
def generate_verification_token() -> str:
"""Generate a verification token for email verification."""
return generate_secure_token(64)
def generate_reset_token() -> str:
"""Generate a password reset token."""
return generate_secure_token(64)
def get_client_ip(request: Request) -> str:
"""Extract the real client IP from the request."""
# Check X-Forwarded-For header first
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Take the first IP in case of multiple proxies
client_ip = forwarded_for.split(",")[0].strip()
try:
# Validate IP address
ipaddress.ip_address(client_ip)
return client_ip
except ValueError:
pass
# Check X-Real-IP header
real_ip = request.headers.get("X-Real-IP")
if real_ip:
try:
ipaddress.ip_address(real_ip)
return real_ip
except ValueError:
pass
# Fallback to request.client.host
client_host = request.client.host if request.client else "unknown"
try:
ipaddress.ip_address(client_host)
return client_host
except ValueError:
return "unknown"
def validate_email(email: str) -> bool:
"""Validate email address format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def validate_username(username: str) -> bool:
"""Validate username format."""
if not username or len(username) < 3 or len(username) > 50:
return False
# Allow alphanumeric, underscore, and hyphen
pattern = r'^[a-zA-Z0-9_-]+$'
return re.match(pattern, username) is not None
def sanitize_filename(filename: str) -> str:
"""Sanitize filename to prevent directory traversal."""
return re.sub(r'[^\w\.-]', '_', filename)
def generate_file_hash(content: bytes) -> str:
"""Generate SHA256 hash of file content."""
return hashlib.sha256(content).hexdigest()
def encrypt_data(data: str, key: bytes) -> str:
"""Encrypt data using NaCl secret box."""
box = nacl.secret.SecretBox(key)
encrypted = box.encrypt(data.encode())
return base64.b64encode(encrypted).decode()
def decrypt_data(encrypted_data: str, key: bytes) -> str:
"""Decrypt data using NaCl secret box."""
box = nacl.secret.SecretBox(key)
encrypted = base64.b64decode(encrypted_data)
decrypted = box.decrypt(encrypted)
return decrypted.decode()
def generate_encryption_key() -> bytes:
"""Generate a new encryption key."""
return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
def format_datetime(dt: datetime) -> str:
"""Format datetime for API responses."""
return dt.isoformat()
def parse_datetime(dt_str: str) -> Optional[datetime]:
"""Parse datetime from API requests."""
try:
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
except:
return None
def calculate_age(birth_date: datetime) -> int:
"""Calculate age from birth date."""
today = datetime.now()
age = today.year - birth_date.year
if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day):
age -= 1
return age
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
"""Truncate text to max length with suffix."""
if len(text) <= max_length:
return text
return text[:max_length - len(suffix)] + suffix
def is_valid_url(url: str) -> bool:
"""Validate URL format."""
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
return re.match(pattern, url) is not None
def generate_device_id() -> str:
"""Generate a unique device identifier."""
return generate_secure_token(32)
def normalize_phone_number(phone: str) -> str:
"""Normalize phone number format."""
# Remove all non-digit characters except +
normalized = re.sub(r'[^\d+]', '', phone)
# Ensure it starts with +
if not normalized.startswith('+'):
if normalized.startswith('00'):
normalized = '+' + normalized[2:]
else:
normalized = '+' + normalized
return normalized
def create_token(user_id: int, username: str, session_id: str) -> str:
# Set a long expiration as safety net (actual expiration based on inactivity)
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
payload = {
"user_id": user_id,
"username": username,
"session_id": session_id,
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def verify_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def _is_admin(user) -> bool:
return user.id == 1
def convert_user(user) -> dict:
return {
"id": user.id,
"created_at": user.created_at.isoformat(),
"last_seen": user.last_seen.isoformat(),
"online": user.online,
"username": user.username,
"display_name": user.display_name,
"profile_picture": user.profile_picture,
"bio": user.bio,
"admin": _is_admin(user),
"verified": user.verified,
"suspended": user.suspended or False,
"suspension_reason": user.suspension_reason,
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
}
+132
View File
@@ -0,0 +1,132 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
import re
def is_valid_username(username: str) -> bool:
if len(username) < 3 or len(username) > 20:
return False
# Only allow English letters, numbers, dashes and underscores
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
return False
return True
def is_valid_display_name(display_name: str) -> bool:
if len(display_name) < 1 or len(display_name) > 64:
return False
# Check if not blank (only whitespace)
if not display_name.strip():
return False
return True
def is_valid_password(password: str) -> bool:
if len(password) < 5 or len(password) > 50:
return False
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
return False
return True
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: Optional[str] = Field(None, max_length=100)
@validator('username')
def username_alphanumeric(cls, v):
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
raise ValueError('Username must be alphanumeric with underscores or hyphens')
return v
@validator('password')
def password_strength(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one digit')
return v
class UserLogin(BaseModel):
username_or_email: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=128)
class UserUpdate(BaseModel):
display_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = Field(None, max_length=500)
avatar_url: Optional[str] = Field(None, max_length=255)
@validator('avatar_url')
def validate_avatar_url(cls, v):
if v and not v.startswith(('http://', 'https://')):
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
return v
class MessageCreate(BaseModel):
content: str = Field(min_length=1, max_length=10000)
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
reply_to_id: Optional[int] = None
recipient_ids: list[int] = Field(min_items=1, max_items=100)
class MessageUpdate(BaseModel):
content: str = Field(min_length=1, max_length=10000)
class DeviceRegister(BaseModel):
device_id: str = Field(min_length=1, max_length=255)
device_name: Optional[str] = Field(None, max_length=255)
device_type: Optional[str] = Field(None, max_length=50)
public_key: Optional[str] = Field(None, max_length=10000)
class PushSubscriptionCreate(BaseModel):
endpoint: str = Field(max_length=500)
p256dh: str = Field(max_length=255)
auth: str = Field(max_length=255)
device_id: Optional[str] = Field(None, max_length=255)
class WebRTCOffer(BaseModel):
offer: dict
participant_ids: list[int] = Field(min_items=1, max_items=10)
class WebRTCAnswer(BaseModel):
answer: dict
session_id: str = Field(max_length=255)
class WebRTCIceCandidate(BaseModel):
candidate: dict
session_id: str = Field(max_length=255)
class ModerationActionCreate(BaseModel):
target_user_id: Optional[int] = None
target_message_id: Optional[int] = None
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
reason: Optional[str] = Field(None, max_length=1000)
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
class PasswordResetRequest(BaseModel):
email: EmailStr
class PasswordReset(BaseModel):
token: str = Field(min_length=64, max_length=64)
new_password: str = Field(..., min_length=8, max_length=128)
@validator('new_password')
def password_strength(cls, v):
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'\d', v):
raise ValueError('Password must contain at least one digit')
return v
class TwoFactorSetup(BaseModel):
code: str = Field(pattern=r'^\d{6}$')
class TwoFactorVerify(BaseModel):
code: str = Field(pattern=r'^\d{6}$')
class EmailVerification(BaseModel):
token: str = Field(min_length=64, max_length=64)
-26
View File
@@ -79,32 +79,6 @@ beta.fromchat.ru {
} }
} }
git.fromchat.ru {
reverse_proxy 172.18.0.1:3000 host.docker.internal:3000 172.17.0.1:3000 {
lb_policy first
header_up X-Real-IP {remote_host}
}
# Security headers
header {
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
X-Frame-Options "DENY" # Prevent clickjacking
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
}
rate_limit {
zone global {
key {remote_ip}
window 1m
burst 20
events 500
}
}
}
api.getgadgets.toolbox-io.ru { api.getgadgets.toolbox-io.ru {
reverse_proxy 95.165.0.162:8400 reverse_proxy 95.165.0.162:8400
} }
+70
View File
@@ -0,0 +1,70 @@
-- Initialize database roles and schemas for FromChat microservices
-- This script creates dedicated users with limited privileges for each service
-- Create service-specific database roles with limited privileges
-- All services use the same password
CREATE ROLE account_service_user LOGIN PASSWORD 'development';
CREATE ROLE profile_service_user LOGIN PASSWORD 'development';
CREATE ROLE device_service_user LOGIN PASSWORD 'development';
CREATE ROLE messaging_service_user LOGIN PASSWORD 'development';
CREATE ROLE push_service_user LOGIN PASSWORD 'development';
CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development';
CREATE ROLE moderation_service_user LOGIN PASSWORD 'development';
CREATE ROLE gateway_user LOGIN PASSWORD 'development';
-- Create dedicated schemas for each service
CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user;
CREATE SCHEMA IF NOT EXISTS profile_schema AUTHORIZATION profile_service_user;
CREATE SCHEMA IF NOT EXISTS device_schema AUTHORIZATION device_service_user;
CREATE SCHEMA IF NOT EXISTS messaging_schema AUTHORIZATION messaging_service_user;
CREATE SCHEMA IF NOT EXISTS push_schema AUTHORIZATION push_service_user;
CREATE SCHEMA IF NOT EXISTS webrtc_schema AUTHORIZATION webrtc_service_user;
CREATE SCHEMA IF NOT EXISTS moderation_schema AUTHORIZATION moderation_service_user;
-- Grant basic connection privileges
GRANT CONNECT ON DATABASE fromchat TO account_service_user, profile_service_user, device_service_user, messaging_service_user, push_service_user, webrtc_service_user, moderation_service_user;
-- Grant schema-level privileges (limited to each service's schema)
-- Account service
GRANT USAGE ON SCHEMA account_schema TO account_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account_schema TO account_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA account_schema TO account_service_user;
-- Profile service
GRANT USAGE ON SCHEMA profile_schema TO profile_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA profile_schema TO profile_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA profile_schema TO profile_service_user;
-- Device service
GRANT USAGE ON SCHEMA device_schema TO device_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device_schema TO device_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA device_schema TO device_service_user;
-- Messaging service
GRANT USAGE ON SCHEMA messaging_schema TO messaging_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA messaging_schema TO messaging_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA messaging_schema TO messaging_service_user;
-- Push service
GRANT USAGE ON SCHEMA push_schema TO push_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA push_schema TO push_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA push_schema TO push_service_user;
-- WebRTC service
GRANT USAGE ON SCHEMA webrtc_schema TO webrtc_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA webrtc_schema TO webrtc_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA webrtc_schema TO webrtc_service_user;
-- Moderation service
GRANT USAGE ON SCHEMA moderation_schema TO moderation_service_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA moderation_schema TO moderation_service_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA moderation_schema TO moderation_service_user;
-- Set default privileges for future objects
ALTER DEFAULT PRIVILEGES IN SCHEMA account_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO account_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA profile_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO profile_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA device_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO device_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA messaging_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA push_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO push_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA webrtc_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO webrtc_service_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA moderation_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO moderation_service_user;
+158
View File
@@ -0,0 +1,158 @@
-- Create tables for FromChat microservices
-- This script creates the necessary tables in their respective schemas
-- Note: In production, tables will be created by Alembic migrations
-- This script provides a fallback or reference for manual setup
-- Account schema tables
CREATE TABLE IF NOT EXISTS account_schema.users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
salt VARCHAR(64) NOT NULL,
display_name VARCHAR(100),
bio TEXT,
avatar_url VARCHAR(255),
is_online BOOLEAN DEFAULT FALSE,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
verified BOOLEAN DEFAULT FALSE,
verification_token VARCHAR(255),
reset_token VARCHAR(255),
reset_token_expires TIMESTAMP,
two_factor_enabled BOOLEAN DEFAULT FALSE,
two_factor_secret VARCHAR(255),
login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP,
public_key TEXT,
private_key TEXT,
encryption_enabled BOOLEAN DEFAULT FALSE,
suspended BOOLEAN DEFAULT FALSE,
suspension_reason TEXT,
deleted BOOLEAN DEFAULT FALSE
);
-- Profile schema tables (references account_schema.users)
CREATE TABLE IF NOT EXISTS profile_schema.user_profiles (
user_id BIGINT PRIMARY KEY REFERENCES account_schema.users(id) ON DELETE CASCADE,
display_name VARCHAR(100),
bio TEXT,
avatar_url VARCHAR(255),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Device schema tables
CREATE TABLE IF NOT EXISTS device_schema.devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
device_id VARCHAR(255) UNIQUE NOT NULL,
device_name VARCHAR(255),
device_type VARCHAR(50),
public_key TEXT,
signed_prekey TEXT,
one_time_prekeys JSONB,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS device_schema.device_session (
id SERIAL PRIMARY KEY,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
raw_user_agent TEXT,
device_name VARCHAR(128),
device_type VARCHAR(32),
os_name VARCHAR(64),
os_version VARCHAR(64),
browser_name VARCHAR(64),
browser_version VARCHAR(64),
brand VARCHAR(64),
model VARCHAR(64),
session_id VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked BOOLEAN DEFAULT FALSE
);
-- Messaging schema tables
CREATE TABLE IF NOT EXISTS messaging_schema.messages (
id BIGSERIAL PRIMARY KEY,
sender_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
content_type VARCHAR(50) DEFAULT 'text',
encrypted_content TEXT,
signature TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
edited_at TIMESTAMP,
edited BOOLEAN DEFAULT FALSE,
deleted BOOLEAN DEFAULT FALSE,
reply_to_id BIGINT REFERENCES messaging_schema.messages(id),
thread_id BIGINT REFERENCES messaging_schema.messages(id),
is_public BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS messaging_schema.message_recipients (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
recipient_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
read_at TIMESTAMP,
delivered_at TIMESTAMP,
encrypted_key TEXT
);
CREATE TABLE IF NOT EXISTS messaging_schema.message_reactions (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
reaction VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Push schema tables
CREATE TABLE IF NOT EXISTS push_schema.push_subscriptions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
device_id BIGINT REFERENCES device_schema.devices(id),
endpoint VARCHAR(500) NOT NULL,
p256dh VARCHAR(255) NOT NULL,
auth VARCHAR(255) NOT NULL,
user_agent VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- WebRTC schema tables
CREATE TABLE IF NOT EXISTS webrtc_schema.webrtc_sessions (
id BIGSERIAL PRIMARY KEY,
session_id VARCHAR(255) UNIQUE NOT NULL,
initiator_id BIGINT REFERENCES account_schema.users(id),
participant_ids JSONB NOT NULL,
offer JSONB,
answer JSONB,
ice_candidates JSONB,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Moderation schema tables
CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions (
id BIGSERIAL PRIMARY KEY,
moderator_id BIGINT REFERENCES account_schema.users(id),
target_user_id BIGINT REFERENCES account_schema.users(id),
target_message_id BIGINT REFERENCES messaging_schema.messages(id),
action_type VARCHAR(50) NOT NULL,
reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP
);
-- Grant permissions on sequences (after all tables are created)
GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user;
GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user;
GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user;
GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user;
GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user;
GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user;
GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user;
GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user;
GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user;
+342 -41
View File
@@ -1,67 +1,368 @@
services: services:
backend: # Database service
build: database:
dockerfile: deployment/Dockerfile.backend image: postgres:15
context: ..
environment: environment:
PORT: 8300 POSTGRES_DB: fromchat
JWT_SECRET: ${JWT_SECRET} POSTGRES_USER: fromchat_admin
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} POSTGRES_PASSWORD: ${DB_PASSWORD}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
FIREBASE_CERT: ${FIREBASE_CERT}
volumes: volumes:
- data:/app/data - database:/var/lib/postgresql/data
- logs:/app/logs - ./db-init:/docker-entrypoint-initdb.d
networks:
- fromchat_internal
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"]
interval: 1s
timeout: 5s
retries: 5
develop: # Migration runner - runs once before other services
watch: migration_runner:
- action: sync+restart
path: ../backend
target: /app
- action: rebuild
path: ../backend/requirements.txt
frontend:
build: build:
dockerfile: deployment/frontend/Dockerfile
context: .. context: ..
dockerfile: docker/Dockerfile.multi
target: migration_runner
environment: environment:
PORT: 8301 DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
BACKEND_HOST: http://backend:8300 JWT_SECRET: ${JWT_SECRET}
ports:
- "8301:8301"
depends_on: depends_on:
- backend database:
condition: service_healthy
networks:
- fromchat_internal
develop: develop:
watch: watch:
- action: rebuild - action: sync
path: ../frontend path: backend/alembic.ini
target: /app/backend/alembic.ini
- action: sync
path: backend/alembic
target: /app/backend/alembic
- action: sync
path: backend/migration.py
target: /app/backend/migration.py
- action: sync
path: backend/services/migration_runner
target: /app/backend/services/migration_runner
- action: sync+restart - action: sync+restart
path: server.js path: backend/shared
target: /server/server.js target: /app/backend/shared
- action: rebuild
path: package.json
# Gateway service - handles complex operations
gateway:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: gateway
ports: ["8300:8300"]
environment:
DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8300
ACCOUNT_SERVICE_URL: http://account_service:8302
PROFILE_SERVICE_URL: http://profile_service:8303
DEVICE_SERVICE_URL: http://device_service:8304
MESSAGING_SERVICE_URL: http://messaging_service:8305
PUSH_SERVICE_URL: http://push_service:8306
WEBRTC_SERVICE_URL: http://webrtc_service:8307
MODERATION_SERVICE_URL: http://moderation_service:8308
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
- fromchat_external
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/app.py
target: /app/backend/app.py
- action: sync
path: backend/main.py
target: /app/backend/main.py
- action: sync
path: backend/dependencies.py
target: /app/backend/dependencies.py
- action: sync
path: backend/security
target: /app/backend/security
- action: sync
path: backend/services/gateway
target: /app/backend/services/gateway
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Account service
account_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: account_service
environment:
DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD}@database:5432/fromchat
JWT_SECRET: ${JWT_SECRET}
PORT: 8302
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/account.py
target: /app/backend/routes/account.py
- action: sync
path: backend/services/account
target: /app/backend/services/account
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Profile service
profile_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: profile_service
environment:
DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
MESSAGING_SERVICE_URL: http://messaging_service:8305
PORT: 8303
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/profile.py
target: /app/backend/routes/profile.py
- action: sync
path: backend/services/profile
target: /app/backend/services/profile
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Device service
device_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: device_service
environment:
DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8304
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/devices.py
target: /app/backend/routes/devices.py
- action: sync
path: backend/services/device
target: /app/backend/services/device
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Messaging service
messaging_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: messaging_service
ports:
- "8305:8305"
environment:
DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
PUSH_SERVICE_URL: http://push_service:8306
PORT: 8305
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/messaging.py
target: /app/backend/routes/messaging.py
- action: sync
path: backend/websocket
target: /app/backend/websocket
- action: sync
path: backend/services/messaging
target: /app/backend/services/messaging
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Push service
push_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: push_service
environment:
DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD}@database:5432/fromchat
FIREBASE_CERT: ${FIREBASE_CERT}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
PORT: 8306
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/push.py
target: /app/backend/routes/push.py
- action: sync
path: backend/push_service.py
target: /app/backend/push_service.py
- action: sync
path: backend/services/push
target: /app/backend/services/push
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# WebRTC service
webrtc_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: webrtc_service
environment:
DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8307
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/webrtc.py
target: /app/backend/routes/webrtc.py
- action: sync
path: backend/services/webrtc
target: /app/backend/services/webrtc
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Moderation service
moderation_service:
build:
context: ..
dockerfile: docker/Dockerfile.multi
target: moderation_service
environment:
DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD}@database:5432/fromchat
PORT: 8308
depends_on:
migration_runner:
condition: service_completed_successfully
networks:
- fromchat_internal
restart: unless-stopped
develop:
watch:
- action: sync
path: backend/routes/moderation.py
target: /app/backend/routes/moderation.py
- action: sync
path: backend/security
target: /app/backend/security
- action: sync
path: backend/similarity.py
target: /app/backend/similarity.py
- action: sync
path: backend/services/moderation
target: /app/backend/services/moderation
- action: sync+restart
path: backend/shared
target: /app/backend/shared
# Caddy reverse proxy
caddy: caddy:
build: build:
context: ./caddy context: ./caddy
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- certs:/root/site/certs - certs:/root/site/certs
networks:
- fromchat_external
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
XDG_DATA_HOME: /root/site/certs - XDG_DATA_HOME=/root/site/certs
XDG_CONFIG_HOME: /root/site/certs - XDG_CONFIG_HOME=/root/site/certs
restart: unless-stopped
depends_on:
- gateway
profiles: ["prod"]
# Frontend service - serves the React app
frontend:
build:
context: ..
dockerfile: deployment/frontend/Dockerfile
ports:
- "8301:8301"
environment:
- PORT=8301
- BACKEND_HOST=http://gateway:8300
restart: unless-stopped
networks:
- fromchat_external
- fromchat_internal
volumes: volumes:
data: database:
name: fromchat-data name: fromchat-database
logs:
name: fromchat-logs
certs: certs:
name: fromchat-certs name: fromchat-certs
logs:
name: fromchat-logs
data:
name: fromchat-data
networks:
fromchat_internal:
driver: bridge
internal: true
fromchat_external:
driver: bridge
+23 -7
View File
@@ -1,28 +1,44 @@
import express from 'express'; import express from 'express';
import type { Request, Response } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware'; import { createProxyMiddleware } from 'http-proxy-middleware';
import { resolve } from 'path'; import { resolve } from 'path';
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = Number(process.env.PORT) || 8301;
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300"; const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
const filePath = process.env.STATIC_FILE_PATH || "."; const filePath = process.env.STATIC_FILE_PATH || ".";
// API proxy middleware // Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
app.use('/api', createProxyMiddleware({ app.use('/api/chat/ws', createProxyMiddleware({
target: 'http://messaging_service:8305',
changeOrigin: true,
pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
ws: true
}));
// API proxy middleware (exclude WebSocket paths)
app.use('/api', (req, res, next) => {
// Skip WebSocket upgrade requests - let them be handled by specific proxies
if (req.headers.upgrade === 'websocket') {
return next();
}
createProxyMiddleware({
target: backendHost, target: backendHost,
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/api': '' }, pathRewrite: { '^/api': '' },
ws: true ws: true
})); })(req, res, next);
});
// Serve static files // Serve static files
app.use(express.static(resolve(filePath))); app.use(express.static(resolve(filePath)));
// SPA routing - catch all handler for client-side routing // SPA routing - catch all handler for client-side routing
app.use((_req, res) => { app.use((_req: Request, res: Response) => {
res.sendFile(resolve(filePath, 'index.html')); res.sendFile(resolve(filePath, 'index.html'));
}); });
app.listen(port, () => { app.listen(port, '0.0.0.0', () => {
console.log(`Server launched on http://localhost:${port}`); console.log(`Backend host: ${backendHost}`);
console.log(`Server launched on http://0.0.0.0:${port}`);
}); });
+123
View File
@@ -0,0 +1,123 @@
# Multi-stage Dockerfile for all FromChat microservices
# This combines the base image and all services in one file
# Base stage - common setup for all services
FROM python:3.11-slim AS base
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd --create-home --shell /bin/bash fromchat
# Set working directory
WORKDIR /app
# Create logs and data directories with proper permissions
RUN mkdir -p /app/backend/logs /app/backend/data /app/backend/data/profanity /app/backend/data/uploads /app/backend/data/uploads/pfp && \
chown -R fromchat:fromchat /app/backend/logs /app/backend/data && \
chmod -R 755 /app/backend/logs /app/backend/data
# Copy requirements first for better caching
COPY backend/requirements.txt /app/requirements.txt
# Install Python dependencies with cache mounts
RUN --mount=type=cache,target=/home/fromchat/.cache/pip \
pip install --no-cache-dir -r requirements.txt
# Copy shared modules
COPY backend/shared /app/backend/shared/
# Copy entrypoint script
COPY docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Switch to non-root user
USER fromchat
# Set entrypoint
ENTRYPOINT ["/app/entrypoint.sh"]
# Account service - minimal files only
FROM base AS account_service
COPY backend/routes/account.py /app/backend/routes/account.py
COPY backend/security /app/backend/security/
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/account/main.py /app/backend/services/account/main.py
ENV SERVICE_NAME=account
# Profile service - minimal files only
FROM base AS profile_service
COPY backend/routes/profile.py /app/backend/routes/profile.py
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
COPY backend/security /app/backend/security/
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
ENV SERVICE_NAME=profile
# Device service - minimal files only
FROM base AS device_service
COPY backend/routes/devices.py /app/backend/routes/devices.py
COPY backend/services/device/main.py /app/backend/services/device/main.py
ENV SERVICE_NAME=device
# Messaging service - minimal files only
FROM base AS messaging_service
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
COPY backend/security /app/backend/security/
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
ENV SERVICE_NAME=messaging
# Push service - minimal files only
FROM base AS push_service
COPY backend/routes/push.py /app/backend/routes/push.py
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
COPY backend/services/push/main.py /app/backend/services/push/main.py
ENV SERVICE_NAME=push
# WebRTC service - minimal files only
FROM base AS webrtc_service
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
ENV SERVICE_NAME=webrtc
# Moderation service - minimal files only
FROM base AS moderation_service
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
COPY backend/security /app/backend/security/
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
ENV SERVICE_NAME=moderation
# Gateway service - minimal files only
FROM base AS gateway
COPY backend/app.py /app/backend/app.py
COPY backend/main.py /app/backend/main.py
COPY backend/dependencies.py /app/backend/dependencies.py
COPY backend/logging_config.py /app/backend/logging_config.py
COPY backend/security /app/backend/security/
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
ENV SERVICE_NAME=gateway
# Migration runner - needs alembic config and migration files
FROM base AS migration_runner
# Temporarily switch back to root to manage file permissions
USER root
COPY backend/alembic.ini /app/backend/alembic.ini
COPY backend/alembic /app/backend/alembic/
COPY backend/migration.py /app/backend/migration.py
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
# Clean up problematic migrations as root
RUN find /app/backend/alembic/versions -name "*auto_generated_migration_for_schema_*" | xargs rm -f || true
# Switch back to fromchat user
USER fromchat
ENV SERVICE_NAME=migration_runner
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Entrypoint script for FromChat microservices
# Run the service module
exec python -m backend.services.${SERVICE_NAME}.main
@@ -1,260 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
import useWindowSize from "@/core/hooks/useWindowSize";
import styles from "./css/split-button.module.scss";
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
interface SplitButtonProps {
text: ReactNode;
icon?: ReactNode | string;
menu: ReactNode;
menuOpen: boolean;
onMenuOpen: (open: boolean) => void;
onPrimaryClick?: () => void;
variant?: SplitButtonVariant;
disabled?: boolean;
className?: string;
menuAriaLabel?: string;
}
export function SplitButton({
text,
icon,
menu,
menuOpen: open,
onMenuOpen,
onPrimaryClick,
variant = "filled",
disabled = false,
className = "",
menuAriaLabel,
}: SplitButtonProps) {
const [isExiting, setIsExiting] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menuPosition, setMenuPosition] = useState<{
top?: number;
bottom?: number;
left: number;
maxHeight: number;
} | null>(null);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const primaryRipple = useRippleHandlers(disabled);
const menuRipple = useRippleHandlers(disabled);
const MENU_GAP = 16;
const EDGE_PAD = 16;
const updateMenuPosition = useCallback(() => {
const anchor = menuSegmentRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const menuEl = menuRef.current;
const menuWidth = menuEl?.offsetWidth ?? 220;
const menuHeight = menuEl?.offsetHeight ?? 320;
const anchorCenterX = rect.left + rect.width / 2;
let left: number;
let top: number | undefined;
let bottom: number | undefined;
let maxHeight: number;
const vw = window.innerWidth;
const vh = window.innerHeight;
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
const fitsBelow = menuHeight <= availableBelow;
const fitsAbove = menuHeight <= availableAbove;
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
if (placeAbove) {
bottom = vh - (rect.top - MENU_GAP);
maxHeight = Math.max(100, availableAbove);
} else {
top = rect.bottom + MENU_GAP;
maxHeight = Math.max(100, availableBelow);
}
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
left = EDGE_PAD;
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
left = vw - menuWidth - EDGE_PAD;
} else {
left = anchorCenterX - menuWidth / 2;
}
setMenuPosition({ top, bottom, left, maxHeight });
}, []);
const closeMenu = useCallback(() => {
onMenuOpen(false);
setIsExiting(true);
}, [onMenuOpen]);
useEffect(() => {
if (!open && !isExiting) {
setMenuPosition(null);
return;
}
if (!open) return;
updateMenuPosition();
window.addEventListener("scroll", updateMenuPosition, true);
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
const target = event.target as Node | null;
if (!target) return;
if (rootRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
closeMenu();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") closeMenu();
}
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", updateMenuPosition, true);
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
useEffect(() => {
if (!open) setIsExiting(true);
}, [open]);
useLayoutEffect(() => {
if (open && menuRef.current) {
updateMenuPosition();
}
}, [open, updateMenuPosition]);
const handlePrimaryClick = () => {
if (disabled) {
return;
}
onPrimaryClick?.();
};
const handleMenuToggle = () => {
if (disabled) return;
if (open) closeMenu();
else onMenuOpen(true);
};
const variantClass =
variant === "tonal"
? styles.variantTonal
: variant === "outlined"
? styles.variantOutlined
: variant === "elevated"
? styles.variantElevated
: styles.variantFilled;
const renderIcon = () => {
if (!icon) {
return null;
}
if (typeof icon === "string") {
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
}
return <span className={styles.leadingIconIcon}>{icon}</span>;
};
const rootClasses = [
styles.splitButton,
variantClass,
disabled ? styles.disabled : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
ref={rootRef}
className={rootClasses}
data-open={open ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
>
<button
type="button"
className={styles.primarySegment}
onClick={handlePrimaryClick}
onPointerDown={primaryRipple.onPointerDown}
onPointerEnter={primaryRipple.onPointerEnter}
onPointerLeave={primaryRipple.onPointerLeave}
disabled={disabled}
>
<MaterialRipple ref={primaryRipple.rippleRef} />
<span className={styles.primaryContent}>
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
<span className={styles.label}>{text}</span>
</span>
</button>
<button
ref={menuSegmentRef}
type="button"
className={styles.menuSegment}
onClick={handleMenuToggle}
onPointerDown={menuRipple.onPointerDown}
onPointerEnter={menuRipple.onPointerEnter}
onPointerLeave={menuRipple.onPointerLeave}
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
aria-label={menuAriaLabel}
>
<MaterialRipple ref={menuRipple.rippleRef} />
<span className={styles.menuIcon}>
<MaterialIcon name="expand_more" />
</span>
</button>
{(open || isExiting) &&
menuPosition &&
createPortal(
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
{open && (
<motion.div
key="menu"
ref={menuRef}
className={styles.menu}
style={{
position: "fixed",
...(menuPosition.bottom != null
? { bottom: menuPosition.bottom }
: { top: menuPosition.top }),
left: menuPosition.left,
maxHeight: menuPosition.maxHeight,
overflowY: "auto",
}}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{menu}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
@@ -1,231 +0,0 @@
@use "../../../css/material" as *;
$height: 40px;
$trailing-width: 48px; // 12 + 22 + 14 per spec
$between-space: 2px;
$outer-radius: calc(#{$height} / 2); // 20px
$inner-radius: 4px;
$inner-radius-hovered: 12px;
.splitButton {
display: inline-flex;
align-items: stretch;
position: relative;
border-radius: $outer-radius;
font-family: inherit;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.1px;
background-color: transparent;
color: $color-dark-on-primary;
isolation: isolate;
&.disabled {
opacity: 0.38;
pointer-events: none;
}
.primarySegment,
.menuSegment {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
background-color: transparent;
color: inherit;
padding: 0;
min-height: $height;
cursor: pointer;
font: inherit;
box-sizing: border-box;
overflow: hidden;
&:focus-visible {
outline: 2px solid $color-dark-primary;
outline-offset: 2px;
}
mdui-ripple {
position: absolute;
inset: 0;
pointer-events: none;
}
}
.primarySegment {
border-top-left-radius: $outer-radius;
border-bottom-left-radius: $outer-radius;
border-top-right-radius: $inner-radius;
border-bottom-right-radius: $inner-radius;
padding-inline: 16px 12px;
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
@media (hover: hover) {
&:hover {
border-top-right-radius: $inner-radius-hovered;
border-bottom-right-radius: $inner-radius-hovered;
}
}
&:active {
border-top-right-radius: $inner-radius-hovered;
border-bottom-right-radius: $inner-radius-hovered;
}
.primaryContent {
display: inline-flex;
align-items: center;
gap: 8px;
pointer-events: none;
user-select: none;
.leadingIcon {
display: inline-flex;
align-items: center;
justify-content: center;
.leadingIconIcon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.label {
white-space: nowrap;
}
}
}
.menuSegment {
width: $trailing-width;
border-top-right-radius: $outer-radius;
border-bottom-right-radius: $outer-radius;
border-top-left-radius: $inner-radius;
border-bottom-left-radius: $inner-radius;
margin-left: $between-space;
padding-inline: 12px 14px;
transition:
border-top-left-radius 0.18s ease-out,
border-bottom-left-radius 0.18s ease-out,
padding-inline 0.18s ease-out;
@media (hover: hover) {
&:hover {
border-top-left-radius: $inner-radius-hovered;
border-bottom-left-radius: $inner-radius-hovered;
}
}
&:active {
border-top-left-radius: $inner-radius-hovered;
border-bottom-left-radius: $inner-radius-hovered;
}
.menuIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
font-size: 22px;
transition: transform 0.18s ease-out;
pointer-events: none;
user-select: none;
mdui-icon {
width: inherit;
height: inherit;
font-size: inherit;
}
}
}
&[data-open="true"] .menuSegment {
$size: calc($trailing-width / 2);
border-radius: $size;
padding-inline: 13px 13px;
.menuIcon {
transform: rotate(-180deg);
}
}
&.variantFilled {
.primarySegment, .menuSegment {
background-color: $color-dark-primary;
color: $color-dark-on-primary;
border: none;
}
}
&.variantTonal {
.primarySegment, .menuSegment {
background-color: $color-dark-primary-container;
color: $color-dark-on-primary-container;
border: none;
}
}
&.variantOutlined {
.primarySegment, .menuSegment {
border: 1px solid rgba($color-dark-outline, 0.8);
border: none;
background-color: transparent;
color: $color-dark-on-surface;
}
}
&.variantElevated {
.primarySegment, .menuSegment {
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.3),
0 1px 2px rgba(0, 0, 0, 0.15);
background-color: $color-dark-surface-container-low;
color: $color-dark-on-surface;
}
}
}
$menu-padding: 8px;
.menu {
padding: $menu-padding;
min-width: 220px;
border-radius: 16px;
background-color: rgba($color-dark-surface-container-high, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 100000000;
// Custom slim semi-transparent scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba($color-dark-on-surface, 0.25);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba($color-dark-on-surface, 0.4);
}
scrollbar-width: thin;
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
mdui-list {
padding: 0;
}
}
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="256" height="256" viewBox="0 0 256 256">
<line x1="1.407" y1="1.353" x2="1.407" y2="1.46" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0, 0, 0); fill-rule: nonzero; opacity: 1;"/>
<g transform="matrix(1.329817, 0, 0, 1.329817, -42.024433, -38.371166)" style="">
<g transform="matrix(1, 0, 0, 1, -0.000001, 0.000008)">
<path d="M 187.533 167.601 C 184.614 169.278 180.394 175.015 176.028 176.522 L 173.703 176.879 C 173.692 176.878 173.682 176.878 173.671 176.877 C 170.911 176.954 167.048 175.793 164.888 173.621 C 164.62 173.351 164.378 173.064 164.167 172.763 C 163.081 170.76 162.591 168.959 162.078 167.519 C 163.681 138.029 142.006 97.682 141.345 89.031 L 140.535 89.081 L 143.379 87.758 C 146.633 86.246 146.577 81.601 143.289 80.165 L 126.072 72.64 C 124.802 72.084 123.338 72.196 122.169 72.941 L 109.131 81.222 C 106.644 82.801 106.537 86.395 108.928 88.118 L 120.16 96.21 C 121.382 97.093 122.987 97.244 124.353 96.607 L 131.993 93.054 L 124.35 96.609 C 122.984 97.244 121.38 97.093 120.157 96.213 L 112.629 90.787 L 112.182 90.815 C 112.182 96.766 92.754 126.354 85.61 153.184 C 82.2 144.438 85.83 134.593 92.372 122.051 C 83.667 131.338 75.779 146.462 83.605 158.699 C 87.499 164.679 93.454 168.284 99.138 171.235 C 103.255 173.904 113.854 176.441 110.918 182.885 C 110.925 182.876 110.931 182.867 110.938 182.859 C 110.931 182.869 110.925 182.88 110.918 182.89 C 108.679 187.681 104.866 190.521 100.012 192.077 C 98.585 189.825 96.894 187.456 94.937 184.967 L 90.627 179.198 C 89.739 177.993 88.66 176.155 87.266 173.682 C 85.934 171.212 84.729 169.31 83.779 167.978 C 82.956 166.519 81.624 165.061 79.912 163.603 C 79.401 163.15 78.861 162.743 78.299 162.386 C 75.967 160.899 72.963 160.587 70.538 161.919 C 69.819 162.313 69.262 162.771 68.883 163.288 C 67.998 164.493 67.489 165.825 67.363 167.219 C 67.172 168.551 66.792 169.439 66.157 169.883 C 66.152 169.886 66.141 169.891 66.135 169.894 C 65.64 163.51 67.408 157.038 70.867 151.489 C 73.595 147.111 75.664 142.699 76.428 138.228 C 76.779 130.993 78.71 123.934 82.113 117.038 C 86.159 107.861 95.837 101.976 98.644 92.659 C 102.474 80.66 83.964 28.579 126.067 28.984 C 150.53 28.984 159.32 50.152 159.32 84.644 C 159.32 107.391 201.316 122.18 187.533 167.601 Z M 151.486 211.429 C 151.801 212.025 152.146 212.581 152.52 213.101 C 136.094 207.361 120.468 206.548 104.498 212.435 C 105.326 211.043 105.778 209.435 105.778 207.781 C 105.778 207.779 105.778 207.778 105.778 207.776 C 105.778 207.761 105.778 207.746 105.778 207.731 C 105.778 207.729 105.778 207.727 105.778 207.725 C 105.778 204.739 104.88 201.359 103.085 197.582 C 107.83 198.93 113.536 199.677 120.393 199.677 C 136.171 200.012 146.507 196.345 152.958 190.163 C 151.666 200.295 149.855 207.938 151.486 211.429 Z M 168.963 155.718 C 167.694 156.465 166.457 157.278 165.336 158.247 C 164.063 159.306 162.925 160.573 162.122 161.99 C 164.732 160.051 167.618 158.932 170.555 157.952 L 170.855 157.851 C 172.058 156.221 173.112 154.49 173.983 152.649 C 179.496 140.288 172.1 126.454 162.265 118.749 C 168.908 130.233 175.535 143.047 168.963 155.718 Z M 110.325 80.46 L 121.571 73.32 C 121.829 72.168 121.972 70.94 121.972 69.664 C 121.972 63.111 118.283 57.8 113.731 57.8 C 109.179 57.8 105.489 63.111 105.489 69.664 C 105.489 74.469 107.476 78.594 110.325 80.46 Z M 140.237 78.831 C 142.075 76.656 143.244 73.357 143.244 69.664 C 143.244 63.111 139.555 57.8 135.002 57.8 C 130.45 57.8 126.761 63.111 126.761 69.664 C 126.761 70.859 126.887 72.008 127.115 73.095 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 97.708 188.673 C 97.691 188.648 97.674 188.626 97.658 188.6 C 97.677 188.626 97.691 188.651 97.708 188.673 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 178.569 117.583 L 162.265 118.749 C 173.339 131.799 177.532 144.523 169.998 156.693 C 166.446 158.264 163.822 160.028 162.122 161.987 C 162.189 163.875 162.178 165.735 162.08 167.551 C 161.65 166.34 161.203 165.379 160.377 164.766 C 160.374 164.774 160.368 164.786 160.363 164.794 C 161.832 165.882 162.13 168.073 163.274 170.855 C 163.316 170.956 163.353 171.052 163.397 171.156 C 163.619 171.676 163.867 172.21 164.167 172.763 C 166.07 175.489 170.571 176.97 173.676 176.883 C 174.039 176.905 174.404 176.88 174.767 176.829 C 174.882 176.812 175 176.781 175.115 176.759 C 175.365 176.708 175.615 176.649 175.863 176.571 C 175.995 176.529 176.124 176.481 176.256 176.43 C 176.495 176.34 176.734 176.236 176.973 176.124 C 177.099 176.065 177.223 176.006 177.349 175.941 C 177.616 175.801 177.883 175.646 178.147 175.48 C 178.237 175.424 178.327 175.376 178.414 175.317 C 178.768 175.087 179.122 174.84 179.471 174.576 C 179.532 174.531 179.591 174.48 179.651 174.432 C 179.943 174.208 180.229 173.977 180.516 173.738 C 180.628 173.646 180.738 173.55 180.848 173.454 C 181.086 173.249 181.325 173.041 181.558 172.831 C 181.668 172.732 181.778 172.631 181.887 172.533 C 182.14 172.302 182.39 172.069 182.638 171.836 C 182.713 171.766 182.789 171.693 182.865 171.622 C 183.202 171.302 183.537 170.987 183.863 170.675 C 184.141 170.411 184.413 170.153 184.68 169.903 C 184.773 169.818 184.86 169.737 184.95 169.655 C 185.155 169.467 185.358 169.282 185.557 169.107 C 185.658 169.017 185.759 168.933 185.861 168.849 C 186.043 168.694 186.223 168.545 186.397 168.408 C 186.496 168.329 186.594 168.253 186.69 168.18 C 186.869 168.045 187.046 167.924 187.218 167.812 C 187.297 167.761 187.378 167.705 187.454 167.66 C 187.482 167.643 187.513 167.621 187.538 167.604 C 194.434 144.871 187.358 129.81 178.569 117.583 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 97.708 188.676 C 97.708 188.676 97.708 188.676 97.708 188.676 C 97.711 188.679 97.711 188.682 97.714 188.685 C 97.714 188.685 97.714 188.685 97.714 188.685 C 98.54 189.845 99.296 190.975 99.995 192.079 C 104.857 190.525 108.676 187.684 110.918 182.888 C 119.916 168.72 59.028 169.675 92.372 122.051 L 80.221 121.306 C 79.123 124.043 78.257 126.806 77.622 129.593 C 77.611 129.638 77.6 129.683 77.589 129.728 C 77.299 131.021 77.069 132.322 76.883 133.628 C 76.849 133.864 76.821 134.103 76.79 134.339 C 76.625 135.629 76.493 136.924 76.431 138.228 C 75.666 142.699 73.598 147.111 70.87 151.489 C 70.46 152.149 70.075 152.823 69.715 153.506 C 69.414 154.08 69.136 154.661 68.872 155.248 C 68.827 155.344 68.776 155.44 68.734 155.535 C 68.127 156.915 67.616 158.328 67.208 159.767 C 66.273 163.066 65.876 166.488 66.141 169.894 C 66.146 169.891 66.157 169.886 66.163 169.883 C 66.798 169.439 67.177 168.551 67.369 167.219 C 67.495 165.825 68.004 164.493 68.889 163.288 C 69.268 162.771 69.824 162.313 70.544 161.919 C 72.969 160.587 75.973 160.899 78.305 162.386 C 78.867 162.743 79.407 163.15 79.918 163.603 C 81.629 165.061 82.961 166.519 83.785 167.978 C 84.734 169.31 85.94 171.212 87.272 173.682 C 88.666 176.155 89.745 177.993 90.633 179.198 L 94.943 184.967 C 95.688 185.914 96.373 186.836 97.037 187.746 C 97.261 188.058 97.492 188.37 97.708 188.676 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 122.169 72.941 C 118.834 75.093 115.509 77.26 112.241 79.513 L 109.8 81.219 C 107.29 82.543 107.108 86.283 109.592 87.601 C 110.213 88.014 120.685 94.772 120.823 94.921 C 121.686 95.556 122.672 95.629 123.597 95.235 C 123.597 95.235 128.913 92.698 128.913 92.698 C 132.521 91.046 136.14 89.416 139.799 87.879 C 140.951 87.263 143.674 86.679 144.295 85.44 C 145.102 84.229 144.885 82.292 143.618 81.301 C 140.94 79.665 136.031 77.493 133.153 75.984 C 133.153 75.984 127.767 73.449 127.767 73.449 C 126.033 72.567 123.979 71.716 122.169 72.941 Z M 122.169 72.941 C 123.948 71.676 126.072 72.486 127.817 73.337 C 127.817 73.337 133.339 75.568 133.339 75.568 C 136.525 76.889 141.575 78.485 144.585 79.985 C 147.94 82.014 147.237 87.637 143.626 89.039 C 139.26 91.341 134.738 93.535 130.282 95.634 C 127.727 96.584 123.675 99.481 120.826 98.191 C 118.682 97.396 110.668 90.321 108.647 88.91 C 105.613 86.881 106.132 81.958 109.417 80.618 C 109.417 80.618 112 79.134 112 79.134 C 115.428 77.13 118.803 75.043 122.169 72.941 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 137.309 83.658 C 129.815 90.793 116.833 92.487 108.451 85.83 C 118.092 89.511 128.081 88.126 137.309 83.658 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 135.177 76.619 C 136.199 75.787 136.995 74.354 137.234 72.634 C 137.638 69.737 136.337 67.158 134.322 66.877 C 132.31 66.596 130.349 68.717 129.942 71.614 C 129.801 72.612 129.877 73.562 130.102 74.402 L 135.177 76.619 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 115.226 77.35 L 118.477 75.284 C 118.94 74.267 119.14 72.98 118.949 71.612 C 118.544 68.715 116.583 66.593 114.568 66.874 C 112.556 67.155 111.252 69.735 111.657 72.632 C 112.008 75.138 113.523 77.055 115.226 77.35 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 59.565 208.06 C 67.843 208.624 75.959 211.078 83.813 213.494 C 87.536 214.444 91.254 216.223 95.12 215.916 C 110.114 213.418 101.209 197.528 95.48 190.025 C 91.948 185.386 87.606 179.844 84.661 174.59 C 82.773 171.676 80.913 167.472 77.864 165.353 C 76.043 163.476 71.974 162.855 70.552 165.308 C 69.26 167.528 70.055 170.085 67.534 171.853 C 66.008 172.746 64.775 172.898 63.246 173.241 C 63.03 173.227 61.69 173.283 61.453 173.275 C 58.157 173.502 53.65 172.238 52.563 176.27 C 51.548 180.165 53.532 184.234 53.389 188.345 C 53.512 192.045 51.677 195.729 49.702 198.539 C 48.876 199.939 48.05 201.462 47.923 202.836 C 47.923 202.836 47.895 202.625 47.895 202.625 C 48.39 204.522 51 205.533 52.799 206.213 C 54.976 206.989 57.264 207.565 59.565 208.043 C 57.219 207.877 54.858 207.683 52.526 207.127 C 50.104 206.433 47.33 205.812 46.257 203.049 C 46.176 198.978 48.98 195.946 49.725 192.984 C 51.425 187.937 48.719 183.45 48.387 178.296 C 48.379 169.56 54.704 168.554 61.099 168.652 C 61.099 168.652 62.988 168.554 62.988 168.554 L 62.49 168.607 C 63.215 168.411 64.415 168.155 64.924 167.826 C 64.362 168.315 64.946 167.716 64.949 166.739 C 65.267 162.259 69.116 158.446 73.638 158.494 C 76.42 158.37 79.069 159.514 81.166 161.259 C 84.762 163.752 87.005 168.211 89.362 171.945 C 94.294 181.041 102.184 188.401 106.051 198.258 C 110.586 208.352 107.428 218.445 95.457 219.623 C 91.046 219.825 86.937 217.515 82.888 216.127 C 75.248 212.887 67.68 209.875 59.565 208.06 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
<path d="M 173.676 176.883 C 170.04 177.085 166.101 176.093 163.628 173.137 C 162.616 171.504 161.905 169.658 161.242 167.975 C 160.756 166.418 159.579 165.201 158.46 165.297 C 154.203 166.106 156.179 175.222 155.81 178.734 C 155.754 184.413 155.22 190.07 154.523 195.628 C 154.133 200.068 152.424 206.837 153.947 210.561 C 155.895 214.048 159.787 216.161 163.743 215.908 C 166.519 215.635 168.883 213.823 171.541 212.008 C 178.285 206.981 184.38 201.231 191.84 196.904 C 194.948 195.108 198.368 193.566 201.861 192.501 C 203.075 192.231 204.952 190.952 205.103 190.143 C 205.053 189.705 204.471 189.193 204.145 188.879 C 201.195 186.423 195.49 184.627 193.473 182.365 C 190.983 180.314 189.73 176.486 189.986 173.477 C 189.927 170.948 189.772 167.112 188.134 168.394 C 183.601 170.858 179.6 177.209 173.676 176.883 C 176.63 176.759 178.805 174.629 180.834 172.746 C 183.436 170.431 189.337 161.785 191.947 168.818 C 193.237 172.586 192.152 177.414 195.718 179.766 C 196.949 180.974 200.987 182.446 202.777 183.326 C 209.203 185.979 212.499 191.309 205.674 195.839 C 201.861 197.685 197.972 198.767 194.414 201.184 C 187.519 205.522 181.325 211.283 174.736 216.332 C 171.822 218.443 168.059 221.036 163.892 221.219 C 157.221 221.539 149.907 217.024 148.541 210.28 C 147.889 205.092 149.28 199.972 149.974 194.959 C 150.904 189.491 151.747 184.065 152.079 178.594 C 152.748 173.682 151.132 163.049 158.578 162.858 C 163.322 163.372 162.987 169.245 164.743 172.446 C 166.393 174.955 170.386 176.7 173.676 176.883 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 879 KiB

-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="200px" height="200px" viewBox="0 0 200 200">
<path d="M 176.226 68.183 C 175.066 69.083 154.581 80.625 154.581 106.291 C 154.581 135.977 180.646 146.48 181.427 146.739 C 181.307 147.38 177.286 161.122 167.684 175.125 C 159.122 187.448 150.18 199.75 136.577 199.75 C 122.974 199.75 119.474 191.849 103.771 191.849 C 88.467 191.849 83.027 200.011 70.583 200.011 C 58.141 200.011 49.46 188.608 39.478 174.605 C 27.915 158.162 18.573 132.617 18.573 108.372 C 18.573 69.484 43.858 48.859 68.744 48.859 C 81.966 48.859 92.988 57.541 101.29 57.541 C 109.191 57.541 121.514 48.339 136.557 48.339 C 142.259 48.339 162.743 48.859 176.226 68.183 Z M 129.416 31.876 C 135.637 24.494 140.038 14.252 140.038 4.01 C 140.038 2.59 139.918 1.149 139.658 -0.011 C 129.536 0.369 117.493 6.73 110.232 15.152 C 104.531 21.634 99.209 31.876 99.209 42.257 C 99.209 43.818 99.47 45.379 99.59 45.879 C 100.23 45.998 101.27 46.139 102.31 46.139 C 111.393 46.139 122.815 40.057 129.416 31.876 Z" style="fill: rgb(255, 255, 255);" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42">
<path fill="#FFFFFF" fill-rule="evenodd" d="M21.47 41.88c-4.11 0-6.02-.6-9.34-3-2.1 2.7-8.75 4.81-9.04 1.2 0-2.71-.6-5-1.28-7.5C1 29.5.08 26.07.08 21.1.08 9.23 9.82.3 21.36.3c11.55 0 20.6 9.37 20.6 20.91a20.6 20.6 0 0 1-20.49 20.67Zm.17-31.32c-5.62-.29-10 3.6-10.97 9.7-.8 5.05.62 11.2 1.83 11.52.58.14 2.04-1.04 2.95-1.95a10.4 10.4 0 0 0 5.08 1.81 10.7 10.7 0 0 0 11.19-9.97 10.7 10.7 0 0 0-10.08-11.1Z" clip-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" width="200px" height="200px">
<path fill-rule="evenodd" clip-rule="evenodd" d="M 13.693 88.614 C 67.369 65.304 103.102 49.815 121.044 42.299 C 172.114 20.984 182.849 17.303 189.751 17.149 C 191.284 17.149 194.658 17.456 196.958 19.295 C 198.799 20.829 199.259 22.823 199.565 24.356 C 199.873 25.889 200.18 29.111 199.873 31.565 C 197.112 60.703 185.15 131.402 179.016 163.916 C 176.408 177.717 171.347 182.317 166.441 182.778 C 155.705 183.699 147.577 175.724 137.302 168.976 C 121.044 158.395 111.997 151.8 96.202 141.371 C 77.951 129.408 89.76 122.815 100.188 112.08 C 102.948 109.319 150.031 66.378 150.95 62.545 C 151.104 62.084 151.104 60.243 150.031 59.323 C 148.956 58.403 147.423 58.71 146.197 59.016 C 144.509 59.323 118.745 76.5 68.596 110.391 C 61.235 115.453 54.64 117.906 48.659 117.754 C 42.065 117.6 29.49 114.072 19.981 111.005 C 8.479 107.325 -0.723 105.331 0.045 98.89 C 0.503 95.516 5.105 92.142 13.693 88.614 Z" style="fill: rgb(255, 255, 255);"></path>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="95" height="95" fill="#ffffff"/>
<rect x="105" y="0" width="95" height="95" fill="#ffffff"/>
<rect x="0" y="105" width="95" height="95" fill="#ffffff"/>
<rect x="105" y="105" width="95" height="95" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 318 B

+68 -39
View File
@@ -1,5 +1,5 @@
import { AuthContainer } from "./Auth"; import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
@@ -8,23 +8,18 @@ import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react"; import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss"; import styles from "./auth.module.scss";
const MIN_HEIGHT = 400;
const slideVariants: Variants = { const slideVariants: Variants = {
enter: (direction: number) => ({ enter: (direction: number) => ({
x: direction > 0 ? 300 : -300, x: direction > 0 ? 300 : -300,
opacity: 0, opacity: 0
y: 0 // Ensure no vertical movement
}), }),
center: { center: {
x: 0, x: 0,
opacity: 1, opacity: 1
y: 0 // Ensure no vertical movement
}, },
exit: (direction: number) => ({ exit: (direction: number) => ({
x: direction > 0 ? -300 : 300, x: direction > 0 ? -300 : 300,
opacity: 0, opacity: 0
y: 0 // Ensure no vertical movement
}) })
}; };
@@ -50,55 +45,80 @@ export default function AuthPage() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const loginFormRef = useRef<HTMLDivElement>(null); const loginFormRef = useRef<HTMLDivElement>(null);
const registerFormRef = useRef<HTMLDivElement>(null); const registerFormRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(400); const [containerHeight, setContainerHeight] = useState<number | "auto">("auto");
const [isTransitioning, setIsTransitioning] = useState(false);
const currentMode = searchParams.get("mode") || "login"; const currentMode = searchParams.get("mode") || "login";
const enteringElementRef = useRef<"login" | "register" | null>(null);
const [effectActivated, setEffectActivated] = useState(false);
useEffect(() => { useEffect(() => {
if (prevMode.current !== currentMode) { if (prevMode.current !== currentMode) {
setDirection(currentMode === "register" ? 1 : -1); setDirection(currentMode === "register" ? 1 : -1);
prevMode.current = currentMode; prevMode.current = currentMode;
setIsTransitioning(true); enteringElementRef.current = currentMode as "login" | "register";
} }
}, [currentMode]); }, [currentMode]);
// Setup ResizeObserver to watch for content changes const measureActiveHeight = useCallback(() => {
useEffect(() => { const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef; if (activeComponent) {
const height = activeComponent.scrollHeight;
if (activeRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height > 0) { if (height > 0) {
setContainerHeight(Math.max(height, MIN_HEIGHT)); setContainerHeight(height);
} }
} }
}, [currentMode, loginFormRef, registerFormRef]);
useLayoutEffect(() => {
if (!effectActivated) {
setEffectActivated(true);
return;
}
// Always measure, but prioritize the entering element during transitions
// Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete
let rafId2: number | null = null;
const rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
measureActiveHeight();
});
}); });
resizeObserver.observe(activeRef.current);
// Initial measurement
const initialHeight = activeRef.current.scrollHeight;
if (initialHeight > 0) {
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
}
return () => { return () => {
resizeObserver.disconnect(); cancelAnimationFrame(rafId1);
}; if (rafId2 !== null) {
cancelAnimationFrame(rafId2);
} }
};
}, [currentMode]); }, [currentMode]);
function switchMode(newMode: "login" | "register") { function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true }); navigate(`/auth?mode=${newMode}`, { replace: true });
} }
function handleAnimationComplete() { function handleAnimationComplete(
setIsTransitioning(false); currentMode: "login" | "register",
} mode: "login" | "register",
enteringElementRef: RefObject<"login" | "register" | null>,
formRef: React.RefObject<HTMLDivElement | null>,
setContainerHeight: (height: number) => void
) {
return () => {
if (currentMode === mode && enteringElementRef.current === mode) {
enteringElementRef.current = null;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (formRef.current && currentMode === mode) {
const height = formRef.current.scrollHeight;
if (height > 0) {
setContainerHeight(height);
}
}
});
});
}
}
}
return ( return (
<AuthContainer> <AuthContainer>
@@ -107,8 +127,14 @@ export default function AuthPage() {
style={{ style={{
position: "relative", position: "relative",
width: "100%", width: "100%",
height: `${containerHeight}px`, height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
transition: isTransitioning ? "height 0.3s ease" : "none" transition: "height 0.3s ease"
}}
onAnimationStart={() => {
}}
onAnimationEnd={() => {
setContainerHeight("auto");
}} }}
> >
<AnimatePresence mode="sync" custom={direction}> <AnimatePresence mode="sync" custom={direction}>
@@ -122,8 +148,11 @@ export default function AuthPage() {
animate="center" animate="center"
exit="exit" exit="exit"
transition={slideTransition} transition={slideTransition}
onAnimationComplete={handleAnimationComplete} onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
className={styles.formWrapper} className={styles.formWrapper}
style={{
position: containerHeight === "auto" ? "relative" : "absolute"
}}
> >
<LoginForm onSwitchMode={() => switchMode("register")} /> <LoginForm onSwitchMode={() => switchMode("register")} />
</motion.div> </motion.div>
@@ -137,7 +166,7 @@ export default function AuthPage() {
animate="center" animate="center"
exit="exit" exit="exit"
transition={slideTransition} transition={slideTransition}
onAnimationComplete={handleAnimationComplete} onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
className={styles.formWrapper} className={styles.formWrapper}
> >
<RegisterForm onSwitchMode={() => switchMode("login")} /> <RegisterForm onSwitchMode={() => switchMode("login")} />
-6
View File
@@ -98,12 +98,6 @@
top: 0; top: 0;
left: 0; left: 0;
&.relative {
position: relative;
top: auto;
left: auto;
}
.authHeader { .authHeader {
margin: 0; margin: 0;
padding: 24px; padding: 24px;
@@ -1,29 +1,28 @@
import { MaterialIcon } from "@/utils/material"; import { MaterialButton } from "@/utils/material";
import styles from "./download-app.module.scss"; import styles from "./download-app.module.scss";
export default function DownloadAppPage() { export default function DownloadAppPage() {
return ( return (
<div className={styles.downloadAppScreen}> <div className={styles.downloadAppScreen}>
<div className={styles.downloadAppCard}> <div>
<h1>Скачайте приложение</h1> <h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
<p> <p>
Этот сайт не предназначен для работы на маленьких экранах. Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
Выберите вашу платформу: вам нужно скачать приложение мессенджера.
</p> </p>
<div className={styles.downloadAppButtons}>
<a href="/download?os=android" className={styles.downloadAppBtn}> <a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<MaterialIcon name="android" /> <MaterialButton>Скачать на GitHub</MaterialButton>
Android
</a> </a>
<a href="/download?os=ios" className={styles.downloadAppBtn}>
<MaterialIcon name="phone_iphone" /> <p>
iOS Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
<a href="https://t.me/denis0001-dev">
<MaterialButton>Написать в поддержку</MaterialButton>
</a> </a>
</div> </div>
<p>
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
</p>
</div> </div>
</div> )
);
} }
@@ -1,70 +1,9 @@
@use "../../css/material" as *;
.downloadAppScreen { .downloadAppScreen {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-width: 100vw; min-width: 100vw;
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 32px;
background-color: $color-dark-surface;
}
.downloadAppCard {
max-width: 400px;
padding: 40px 32px;
background: rgba($color-dark-surface-container, 0.6);
border: 1px solid rgba($color-dark-outline, 0.3);
border-radius: 24px;
text-align: center;
h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px;
color: $color-dark-on-surface;
}
p {
color: $color-dark-on-surface-variant;
margin: 0 0 24px;
font-size: 16px;
a {
color: $color-dark-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.downloadAppButtons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 24px;
}
.downloadAppBtn {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: rgba($color-dark-primary, 0.2);
border: 1px solid rgba($color-dark-primary, 0.5);
border-radius: 12px;
color: $color-dark-on-surface;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background: rgba($color-dark-primary, 0.3);
border-color: $color-dark-primary;
}
} }
-112
View File
@@ -1,112 +0,0 @@
import type { ReactNode } from "react";
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialIcon } from "@/utils/material";
import { OS_CONFIG, type DownloadOs } from "@/pages/home/os";
import styles from "@/pages/home/download-dialog.module.scss";
interface DownloadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
os: DownloadOs;
}
function AndroidInstructions(): ReactNode {
return (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Установка на Android</h3>
<p className={styles.text}>
Вы скачали APK-файл FromChat. Чтобы установить приложение:
</p>
<ul className={styles.list}>
<li>Откройте загруженный APK-файл из шторки уведомлений или файлового менеджера.</li>
<li>
Если появится запрос &quot;Разрешить установку из неизвестных источников&quot; дайте
разрешение для браузера, из которого вы скачивали APK.
</li>
<li>
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
нажмите &quot;Подробнее&quot; &quot;Всё равно установить&quot; (или аналогичную кнопку).
</li>
<li>Дождитесь завершения установки и откройте FromChat из списка приложений.</li>
</ul>
</div>
);
}
function IosInstructions(): ReactNode {
return (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Установка на iOS</h3>
<p className={styles.text}>
Эта сборка не распространяется через App Store или TestFlight. Чтобы установить FromChat на iPhone
или iPad, потребуется один из вариантов сторонней установки:
</p>
<ul className={styles.list}>
<li>
<strong>TrollStore</strong>: постоянная установка приложений из IPA-файлов. Требуется поддерживаемая
версия iOS и настройка TrollStore на устройстве.
</li>
<li>
<strong>Джейлбрейк</strong>: установка через менеджер пакетов (Sileo, Cydia и т.п.) или напрямую
из файлового менеджера, если у вас уже есть джейлбрейк.
</li>
<li>
<strong>Другие сервисы сайдлоада</strong>: сторонние инструменты, которые подписывают IPA-файл
вашим сертификатом разработчика или временным сертификатом.
</li>
</ul>
<p className={styles.text}>
К сожалению, простого и официально поддерживаемого пути установки для iOS здесь нет именно поэтому я
бы сам iPhone не покупал 😄
</p>
</div>
);
}
function renderInstructions(os: DownloadOs): ReactNode {
if (os === "android") {
return <AndroidInstructions />;
}
if (os === "ios") {
return <IosInstructions />;
}
return null;
}
export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps) {
const osInfo = OS_CONFIG[os];
return (
<StyledDialog
open={open}
onOpenChange={onOpenChange}
className={styles.downloadDialog}
contentClassName={styles.downloadDialogContent}
afterChildren={
<div className={styles.actions}>
<MaterialButton variant="filled" onClick={() => onOpenChange(false)}>
Закрыть
</MaterialButton>
</div>
}
>
<div className={styles.body}>
<div className={styles.header}>
<div className={styles.iconWrapper}>
<MaterialIcon name="download" className={styles.icon} />
</div>
<div className={styles.titleBlock}>
<h2 className={styles.title}>Спасибо за скачивание!</h2>
<p className={styles.subtitle}>
FromChat для&nbsp;
<span className={styles.osName}>{osInfo.label}</span>
</p>
</div>
</div>
{renderInstructions(os)}
</div>
</StyledDialog>
);
}
-95
View File
@@ -1,95 +0,0 @@
import { Link } from "react-router-dom";
import { MaterialIcon } from "@/utils/material";
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "@/pages/home/homeLinks";
import styles from "@/pages/home/home-footer.module.scss";
interface HomeFooterProps {
onScrollToDownload?: () => void;
}
export function HomeFooter({ onScrollToDownload }: HomeFooterProps) {
return (
<footer className={styles.homepageFooter}>
<div className={styles.footerBrand}>
<div className={styles.footerLogoRow}>
<div className={styles.footerLogo} />
<span className={styles.footerBrandName}>FromChat</span>
</div>
<p className={styles.footerCopyright}>FromChat © 2026</p>
</div>
<div className={styles.footerLinks}>
<div className={styles.footerSection}>
<button
type="button"
onClick={onScrollToDownload}
className={styles.footerLink}
>
<MaterialIcon name="download" className={styles.footerLinkIcon} />
Скачать приложение
</button>
<Link to="/login" className={styles.footerLink}>
<MaterialIcon name="language" className={styles.footerLinkIcon} />
Веб-версия
</Link>
<a
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
ПК-клиент
</a>
</div>
<div className={styles.footerSection}>
<a
href={`${GITHUB_APP}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="android" className={styles.footerLinkIcon} />
Исходный код приложения
</a>
<GitHubLink className={styles.footerLink}>
<MaterialIcon name="code" className={styles.footerLinkIcon} />
Исходный код веб-версии
</GitHubLink>
<a
href={GITHUB_LICENSE}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="description" className={styles.footerLinkIcon} />
Лицензия
</a>
</div>
<div className={styles.footerSection}>
<a
href="https://t.me/fromchat_ch"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
/>
Telegram
</a>
<a
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
/>
MAX
</a>
</div>
</div>
</footer>
);
}
-76
View File
@@ -1,76 +0,0 @@
import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import { GitHubLink, SupportLink } from "@/pages/home/homeLinks";
import styles from "@/pages/home/home-header.module.scss";
interface HomeHeaderProps {
onScrollToDownload?: () => void;
}
export function HomeHeader({ onScrollToDownload }: HomeHeaderProps) {
const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
const handleMobileDownload = () => {
onScrollToDownload?.();
};
function handleGetStarted() {
if (isMobile) {
handleMobileDownload();
} else if (isLoggedIn) {
navigate("/chat");
} else {
navigate("/login");
}
}
const openBtn = (
<MaterialButton
variant="filled"
onClick={handleGetStarted}
icon={
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
}
className={styles.headerDownloadButton}
>
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
</MaterialButton>
);
return (
<header className={styles.homepageHeader} data-home-header>
<div className={styles.headerInner}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<div className={styles.logoIcon} />
<h1>FromChat</h1>
</div>
<div className={styles.headerCenterLinks}>
<GitHubLink>
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
</SupportLink>
</div>
<div className={styles.headerButton}>
{openBtn}
{isMobile ? (
<MaterialIconButton
variant="filled"
onClick={handleMobileDownload}
icon="download"
className={styles.headerSmallButton}
/>
) : null}
</div>
</div>
</div>
</header>
);
}
+209 -202
View File
@@ -1,236 +1,228 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useRef, useState, type ReactNode } from "react"; import { useUserStore } from "@/state/user";
import styles from "@/pages/home/home.module.scss"; import styles from "./home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; import { MaterialButton, MaterialIcon } from "@/utils/material";
import generalChatScreenshot from "@/images/screenshots/general-chat.png";
import dmScreenshot from "@/images/screenshots/dm.png";
import windowsIcon from "@/images/windows.svg";
import linuxIcon from "@/images/linux.svg";
import macIcon from "@/images/mac.svg";
import { HomeHeader } from "@/pages/home/HomeHeader";
import { HomeFooter } from "@/pages/home/HomeFooter";
import { SplitButton } from "@/core/components/SplitButton";
import { DownloadDialog } from "@/pages/home/DownloadDialog";
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
interface FeatureSectionProps { function GitHubLink({ children }: { children: React.ReactNode }) {
title: ReactNode; return (
children: ReactNode; <a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
screenshot: string; );
right?: boolean;
} }
function FeatureSection({ function SupportLink({ children }: { children: React.ReactNode }) {
title,
children,
screenshot,
right = false,
}: FeatureSectionProps) {
const featureText = (
<div className={styles.featureText}>
<div className={styles.featureTitle}>{title}</div>
<div className={styles.featureDesc}>{children}</div>
</div>
);
const featureScreenshot = (
<div className={styles.featureScreenshotOuter}>
<div className={styles.featureScreenshotGlow} />
<img src={screenshot} className={styles.featureScreenshot} draggable={false} />
</div>
)
return ( return (
<div className={`${styles.featureContainer}`}> <a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>} );
</div>
)
} }
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen(); const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
const [dialogOpen, setDialogOpen] = useState(false); function handleGetStarted() {
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs()); if (isMobile) {
const [menuOpen, setMenuOpen] = useState(false); navigate("/download-app");
const downloadSectionRef = useRef<HTMLElement>(null); } else if (isLoggedIn) {
navigate("/chat");
const scrollToDownload = () => { } else {
const section = downloadSectionRef.current; navigate("/login");
const header = document.querySelector<HTMLElement>("[data-home-header]"); }
if (!section) return;
const headerHeight = header?.getBoundingClientRect().height ?? 0;
const targetY = section.getBoundingClientRect().top + window.scrollY - headerHeight;
window.scrollTo({ top: targetY, behavior: "smooth" });
};
const triggerDownload = (os: DownloadOs): boolean => {
if (typeof document === "undefined") {
return false;
} }
setDialogOs(os); const openBtn = (
setDialogOpen(true); <MaterialButton variant="filled" onClick={handleGetStarted}>
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
const link = document.createElement("a"); </MaterialButton>
link.href = `/api/download/${os}`; );
link.download = "";
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return true;
};
const getButtonVariant = (os: DownloadOs): "filled" | "tonal" | "outlined" => {
const detectedOs = detectOs();
if (os === detectedOs) return "filled";
if (!isMobile && ["windows", "linux", "macos"].includes(os)) return "tonal";
if (isMobile && (os === "android" || os === "ios") && os !== detectedOs) return "tonal";
return "outlined";
};
return ( return (
<div className={styles.homepage}> <div className={styles.homepage}>
<HomeHeader onScrollToDownload={scrollToDownload} /> <header className={styles.homepageHeader}>
<div className={styles.container}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<h1>FromChat</h1>
<span className={styles.tagline}>100% открытый мессенджер</span>
</div>
<nav className={styles.headerNav}>
<GitHubLink>
<MaterialButton variant="text">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text">Поддержка</MaterialButton>
</SupportLink>
{openBtn}
</nav>
</div>
</div>
</header>
<main> <main>
<section className={styles.title}> <section className={styles.hero}>
<div className={styles.titleLogoWrapper}> <div className={styles.container}>
<div className={styles.titleLogo} /> <div className={styles.heroContent}>
</div> <h2 className={styles.heroTitle}>
<div className={styles.titleContent}>FromChat</div> Безопасный мессенджер с открытым исходным кодом
<div className={styles.titleDesc}> </h2>
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере. <p className={styles.heroDescription}>
</div> FromChat это полностью открытый мессенджер с end-to-end шифрованием,
<div className={styles.titleButtons}> поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
{isMobile ? null : ( </p>
<div className={styles.heroActions}>
{openBtn}
{!isMobile && (
<MaterialButton <MaterialButton
variant="filled" variant="outlined"
onClick={() => navigate("/auth?mode=login")} onClick={() => navigate("/register")}>
icon="devices" Зарегистрироваться
>
Открыть веб-версию
</MaterialButton> </MaterialButton>
)} )}
<SplitButton </div>
variant={isMobile ? "filled" : "tonal"} </div>
text="Скачать приложение" <div className={styles.heroVisual}>
icon="download" <div className={styles.chatPreview}>
onPrimaryClick={() => triggerDownload(detectOs())} <div className={styles.chatWindow}>
menuOpen={menuOpen} <div className={styles.chatHeader}>
onMenuOpen={setMenuOpen} <div className={styles.chatTitle}>Общий чат</div>
menu={( <div className={styles.onlineIndicator}></div>
<MaterialList> </div>
{ALL_OS.map((os) => ( <div className={styles.chatMessages}>
<MaterialListItem <div className={`${styles.message} ${styles.received}`}>
key={os} <div className={styles.messageAvatar}>А</div>
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon} <div className={styles.messageContent}>
headline={OS_CONFIG[os].label} <div className={styles.messageText}>Привет! Как дела?</div>
rounded <div className={styles.messageTime}>14:30</div>
onClick={() => { </div>
if (triggerDownload(os)) setMenuOpen(false); </div>
}} <div className={`${styles.message} ${styles.sent}`}>
> <div className={styles.messageContent}>
{["windows", "linux", "macos"].includes(os) && ( <div className={styles.messageText}>Всё отлично! А у тебя как?</div>
<span <div className={styles.messageTime}>14:32</div>
slot="icon" </div>
className={styles.menuCustomIcon} </div>
style={{ <div className={`${styles.message} ${styles.received}`}>
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`, <div className={styles.messageAvatar}>Б</div>
} as React.CSSProperties} <div className={styles.messageContent}>
/> <div className={styles.messageText}>Отправляю файл 📎</div>
)} <div className={styles.messageTime}>14:35</div>
</MaterialListItem> </div>
))} </div>
</MaterialList> </div>
)} </div>
/> </div>
</div>
</div> </div>
</section> </section>
<section className={styles.features}> <section className={styles.features}>
<FeatureSection <div className={styles.container}>
title={<>Общий чат</>} <h3 className={styles.sectionTitle}>Возможности</h3>
screenshot={generalChatScreenshot} <div className={styles.featuresGrid}>
right <div className={styles.featureCard}>
> <div className={styles.featureIcon}>
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени. <MaterialIcon name="security" />
</FeatureSection> </div>
<FeatureSection <h4>End-to-End Шифрование</h4>
title={<>Личные сообщения</>} <p>
screenshot={dmScreenshot}> Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Общайтесь с одним человеком в личной переписке. Только вы и получатель можете прочитать сообщения.
</FeatureSection> </p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="code" />
</div>
<h4>100% открытый код</h4>
<p>
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
внести изменения или развернуть свой сервер.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="attach_file" />
</div>
<h4>Обмен Файлами</h4>
<p>
Отправляйте файлы до 4 ГБ. Файлы в личных сообщениях шифруются.
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="notifications" />
</div>
<h4>Уведомления</h4>
<p>
Получайте push-уведомления в браузере и настольном приложении.
Никогда не пропустите важное сообщение.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="edit" />
</div>
<h4>Редактирование</h4>
<p>
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
для лучшего контекста общения.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="computer" />
</div>
<h4>Кроссплатформенность</h4>
<p>
Работает в браузере и как настольное приложение для Windows,
macOS и Linux. Единый интерфейс везде.
</p>
</div>
</div>
</div>
</section> </section>
<section ref={downloadSectionRef} className={styles.download}> <section className={styles.download}>
<div className={styles.container}> <div className={styles.container}>
<div className={styles.downloadContent}> <div className={styles.downloadContent}>
<h3>Скачайте приложение</h3> <h3>Скачайте приложение</h3>
<p> <p>
Настольное приложение с уведомлениями и автономной работой Для лучшего опыта используйте настольное приложение с поддержкой
или мобильное приложение для Android и iOS. уведомлений и автономной работы.
</p> </p>
<table className={styles.downloadTable}> <div className={styles.downloadButtons}>
<thead> {!isMobile ? (
<tr> <>
<th>Платформа</th> <a
<th>Описание</th> href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
<th /> target="_blank"
</tr> rel="noopener noreferrer"
</thead>
<tbody>
{ALL_OS.map((os) => (
<tr key={os}>
<td className={styles.downloadTableOs}>
<span className={styles.downloadTableOsContent}>
{["windows", "linux", "macos"].includes(os) ? (
<span
className={styles.tableOsIcon}
style={{
"--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
} as React.CSSProperties}
/>
) : (
<MaterialIcon
name={OS_CONFIG[os].icon}
className={styles.downloadTableIcon}
/>
)}
<span>{OS_CONFIG[os].label}</span>
</span>
</td>
<td className={styles.downloadTableDesc}>
<span className={styles.downloadTableDescInner}>
{OS_CONFIG[os].description}
</span>
</td>
<td className={styles.downloadTableAction}>
<span className={styles.downloadTableActionInner}>
<MaterialButton
variant={getButtonVariant(os)}
icon="download"
className={styles.downloadButton}
onClick={() => triggerDownload(os)}
> >
Скачать <MaterialButton variant="filled">
<MaterialIcon name="download" slot="icon" />
Скачать для ПК
</MaterialButton> </MaterialButton>
<MaterialIconButton </a>
variant={getButtonVariant(os)} <MaterialButton variant="outlined" onClick={() => navigate("/login")}>
icon="download" <MaterialIcon name="language" slot="icon" />
className={styles.downloadButtonIcon} Веб-версия
onClick={() => triggerDownload(os)} </MaterialButton>
title={`Скачать ${OS_CONFIG[os].label}`} </>
/> ) : (
</span> <MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
</td> Скачать приложение
</tr> </MaterialButton>
))} )}
</tbody> </div>
</table>
</div> </div>
</div> </div>
</section> </section>
@@ -240,8 +232,7 @@ export default function HomePage() {
<div className={styles.ctaContent}> <div className={styles.ctaContent}>
<h3>Готовы начать общение?</h3> <h3>Готовы начать общение?</h3>
<p> <p>
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
или звоните всё бесплатно и с открытым кодом.
</p> </p>
<div className={styles.ctaActions}> <div className={styles.ctaActions}>
{isMobile ? ( {isMobile ? (
@@ -268,8 +259,24 @@ export default function HomePage() {
</section> </section>
</main> </main>
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} /> <footer className={styles.homepageFooter}>
<HomeFooter onScrollToDownload={scrollToDownload} /> <div className={styles.container}>
<div className={styles.footerContent}>
<div className={styles.footerSection}>
<h4>Ссылки</h4>
<GitHubLink>GitHub</GitHubLink>
<SupportLink>Поддержка</SupportLink>
</div>
<div className={styles.footerSection}>
<h4>Лицензия</h4>
<p>GPL-3.0</p>
</div>
</div>
<div className={styles.footerBottom}>
<p>&copy; 2025 FromChat. Сделано программистом denis0001-dev с для свободы общения.</p>
</div>
</div>
</footer>
</div> </div>
); );
} }
-11
View File
@@ -1,11 +0,0 @@
@use "@/css/material" as *;
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
$glow-purple: rgba(147, 51, 234, 0.5);
@mixin gradient-text {
background: $gradient-rainbow;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px $glow-purple;
}
@@ -1,113 +0,0 @@
@use "@/css/material" as *;
@use "@/css/colors" as *;
@use "sass:color";
.downloadDialog {
color: $color-dark-on-surface;
}
.downloadDialogContent {
padding: 24px 24px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
.header {
display: flex;
align-items: flex-start;
gap: 16px;
}
.iconWrapper {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: $color-dark-primary-container;
color: $color-dark-on-primary-container;
}
.icon {
font-size: 22px;
}
.titleBlock {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.subtitle {
margin: 0;
font-size: 14px;
color: $color-dark-on-surface-variant;
}
.osName {
font-weight: 600;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionTitle {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.text {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: $color-dark-on-surface-variant;
}
.list {
margin: 0;
padding-left: 20px;
font-size: 14px;
line-height: 1.6;
color: $color-dark-on-surface-variant;
li + li {
margin-top: 4px;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
border-top: 1px solid rgba($color-dark-outline-variant, 0.4);
background-color: color.mix($color-dark-surface-container, $color-dark-surface-container-low, 60%);
}
@media (max-width: 480px) {
.downloadDialogContent {
padding: 20px 16px 12px;
}
.actions {
padding-inline: 16px;
}
}
@@ -1,157 +0,0 @@
@use "@/css/material" as *;
@use "home-shared" as shared;
.homepageFooter {
padding: 0 16px;
background: $color-dark-surface;
display: flex;
flex-direction: row;
gap: 32px;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
align-items: center;
justify-content: center;
padding-bottom: 48px;
@media (max-width: 635px) {
flex-direction: column-reverse;
align-items: flex-start;
padding: 32px;
}
}
.footerBrand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.footerLogoRow {
display: flex;
align-items: center;
gap: 10px;
}
.footerLogo {
width: 40px;
height: 40px;
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.footerBrandName {
font-size: 32px;
font-weight: 700;
@include shared.gradient-text;
user-select: none;
}
.footerCopyright {
font-size: 14px;
color: $color-dark-on-surface-variant;
opacity: 0.8;
margin: 0;
}
.footerLinks {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.footerSection {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.footerLink {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: $color-dark-on-surface-variant;
text-decoration: none;
font-size: 15px;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
transition: color 0.2s ease, transform 0.15s ease;
-webkit-user-drag: none;
user-select: none;
&:hover {
color: $color-dark-on-surface;
}
}
button.footerLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
}
button.footerLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
}
button.footerLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
&:is(button) {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
}
&:active {
transform: scale(0.92);
}
}
.footerLinkIcon {
width: 24px;
height: 24px;
min-width: 24px;
opacity: 0.9;
}
.footerLinkIconSvg {
display: inline-block;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.footerLinkIconSvgTelegram {
mask-image: url('@/images/telegram.svg');
-webkit-mask-image: url('@/images/telegram.svg');
}
.footerLinkIconSvgMax {
mask-image: url('@/images/max.svg');
-webkit-mask-image: url('@/images/max.svg');
}
@@ -1,90 +0,0 @@
@use "@/css/material" as *;
@use "home-shared" as shared;
.homepageHeader {
display: flex;
justify-content: center;
padding: 16px;
position: sticky;
top: 0;
z-index: 1000;
user-select: none;
}
.headerInner {
max-width: 960px;
width: 100%;
padding: 16px 24px;
background: rgba($color-dark-surface, 0.8);
backdrop-filter: blur(10px);
border-radius: 30px;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logoIcon {
width: 40px;
height: 40px;
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.logo h1 {
font-size: 32px;
font-weight: 700;
margin: 0;
@include shared.gradient-text;
}
.headerCenterLinks {
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 730px) {
display: none;
}
a {
display: flex;
align-items: center;
gap: 10px;
}
}
.headerButton {
display: flex;
align-items: center;
justify-content: center;
}
.headerDownloadButton {
display: block;
}
.headerSmallButton {
display: none;
}
@media (max-width: 450px) {
.headerSmallButton {
display: block;
}
.headerDownloadButton {
display: none;
}
}
File diff suppressed because it is too large Load Diff
-43
View File
@@ -1,43 +0,0 @@
const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
export const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
export function GitHubLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href={`${GITHUB_WEB}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
export function SupportLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href="https://t.me/denis0001-dev"
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
export { GITHUB_WEB, GITHUB_APP };
-56
View File
@@ -1,56 +0,0 @@
export type DownloadOs = "windows" | "linux" | "macos" | "android" | "ios";
export const ALL_OS: DownloadOs[] = [
"windows",
"linux",
"macos",
"android",
"ios",
] as const;
export interface OsInfo {
id: DownloadOs;
label: string;
description: string;
icon: string;
}
export const OS_CONFIG: Record<DownloadOs, OsInfo> = {
windows: { id: "windows", label: "Windows", description: "ПК", icon: "computer" },
linux: { id: "linux", label: "Linux", description: "ПК", icon: "computer" },
macos: { id: "macos", label: "macOS", description: "Apple", icon: "computer" },
android: { id: "android", label: "Android", description: "APK", icon: "android" },
ios: { id: "ios", label: "iOS", description: "iPhone, iPad", icon: "phone_iphone" },
};
export function detectOs(): DownloadOs {
if (typeof navigator === "undefined") {
return "android";
}
const ua = (navigator.userAgent || navigator.platform || "").toLowerCase();
const platform = (navigator as any).userAgentData?.platform?.toLowerCase?.() ?? "";
const haystack = `${ua} ${platform}`;
if (haystack.includes("android")) {
return "android";
}
if (haystack.includes("iphone") || haystack.includes("ipad") || haystack.includes("ipod")) {
return "ios";
}
if (haystack.includes("win")) {
return "windows";
}
if (haystack.includes("mac")) {
return "macos";
}
if (haystack.includes("linux")) {
return "linux";
}
return "android";
}
+1 -70
View File
@@ -17,21 +17,18 @@ import 'mdui/components/button';
import 'mdui/components/text-field'; import 'mdui/components/text-field';
import 'mdui/components/button-icon'; import 'mdui/components/button-icon';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/ripple';
import 'mdui/components/chip'; import 'mdui/components/chip';
import 'mdui/components/badge'; import 'mdui/components/badge';
import "mdui/mdui.css"; import "mdui/mdui.css";
import 'mdui/components/circular-progress'; import 'mdui/components/circular-progress';
import { useCallback, useRef } from "react";
import { setColorScheme } from 'mdui/functions/setColorScheme'; import { setColorScheme } from 'mdui/functions/setColorScheme';
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref, RefObject } from 'react'; import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react';
import type { TextField } from 'mdui/components/text-field'; import type { TextField } from 'mdui/components/text-field';
import type { Switch } from 'mdui/components/switch'; import type { Switch } from 'mdui/components/switch';
import type { Override } from '@/core/types'; import type { Override } from '@/core/types';
import type { Button } from 'mdui/components/button'; import type { Button } from 'mdui/components/button';
import type { ButtonIcon } from 'mdui/components/button-icon'; import type { ButtonIcon } from 'mdui/components/button-icon';
import type { Ripple } from 'mdui/components/ripple';
import type { Icon } from 'mdui/components/icon'; import type { Icon } from 'mdui/components/icon';
import type { Fab } from 'mdui/components/fab'; import type { Fab } from 'mdui/components/fab';
import type { Tabs } from 'mdui/components/tabs'; import type { Tabs } from 'mdui/components/tabs';
@@ -61,7 +58,6 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
export type MDUIButton = Override<HTMLElement, Button>; export type MDUIButton = Override<HTMLElement, Button>;
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>; export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
export type MDUIIcon = Override<HTMLElement, Icon>; export type MDUIIcon = Override<HTMLElement, Icon>;
export type MDUIRipple = Override<HTMLElement, Ripple>;
export type MDUIFab = Override<HTMLElement, Fab>; export type MDUIFab = Override<HTMLElement, Fab>;
export type MDUITabs = Override<HTMLElement, Tabs>; export type MDUITabs = Override<HTMLElement, Tabs>;
export type MDUITab = Override<HTMLElement, Tab>; export type MDUITab = Override<HTMLElement, Tab>;
@@ -145,68 +141,3 @@ export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-b
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) { export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} /> return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
} }
export type MaterialRippleProps = NoChildren<BasePropCustomization<"mdui-ripple", MDUIRipple>>;
export function MaterialRipple(props: MaterialRippleProps) {
return <mdui-ripple {...props as ComponentProps<"mdui-ripple">} />
}
/**
* Returns pointer handlers that forward press and hover events to an mdui-ripple element.
* Pass the returned ref to MaterialRipple and spread the handlers onto the container (e.g. button).
*
* @example
* const { rippleRef, ...rippleHandlers } = useRippleHandlers(disabled);
* <button {...rippleHandlers}>
* <MaterialRipple ref={rippleRef} />
* ...
* </button>
*/
export function useRippleHandlers(
disabled = false
): {
rippleRef: RefObject<MDUIRipple | null>;
onPointerDown: (e: React.PointerEvent) => void;
onPointerEnter: (e: React.PointerEvent) => void;
onPointerLeave: (e: React.PointerEvent) => void;
} {
const rippleRef = useRef<MDUIRipple | null>(null);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled || e.button !== 0) return;
const ripple = rippleRef.current;
if (!ripple?.startPress) return;
ripple.startPress(e.nativeEvent);
const btn = e.currentTarget as HTMLElement;
const endPress = () => {
ripple.endPress?.();
btn.removeEventListener("pointerup", endPress);
btn.removeEventListener("pointercancel", endPress);
btn.removeEventListener("pointerleave", endPress);
};
btn.addEventListener("pointerup", endPress);
btn.addEventListener("pointercancel", endPress);
btn.addEventListener("pointerleave", endPress);
},
[disabled]
);
const onPointerEnter = useCallback(
(e: React.PointerEvent) => {
if (disabled || e.pointerType !== "mouse") return;
rippleRef.current?.startHover?.();
},
[disabled]
);
const onPointerLeave = useCallback(
(e: React.PointerEvent) => {
if (disabled || e.pointerType !== "mouse") return;
rippleRef.current?.endHover?.();
},
[disabled]
);
return { rippleRef, onPointerDown, onPointerEnter, onPointerLeave };
}
-6
View File
@@ -8,12 +8,6 @@ declare global {
interface SyntheticEvent<T = Element, E = Event> { interface SyntheticEvent<T = Element, E = Event> {
target: EventTarget & T; target: EventTarget & T;
} }
namespace JSX {
interface IntrinsicElements {
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
}
}
} }
// Augment DOM event listeners to provide typed target for ALL elements // Augment DOM event listeners to provide typed target for ALL elements
+22 -84
View File
@@ -314,6 +314,8 @@ step "Detecting services"
cd "$DEPLOYMENT_DIR" cd "$DEPLOYMENT_DIR"
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null) SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
# Note: Using multi-stage Dockerfile - no separate base image build needed
if [ -z "$SERVICES" ]; then if [ -z "$SERVICES" ]; then
error "No services found in docker-compose.yml" error "No services found in docker-compose.yml"
fi fi
@@ -341,6 +343,10 @@ for SERVICE in $SERVICES; do
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \ sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
tr -d '"' | tr -d "'" | xargs) tr -d '"' | tr -d "'" | xargs)
TARGET=$(echo "$BUILD_OUTPUT" | grep "target:" | \
sed 's/.*target:[[:space:]]*\(.*\)/\1/' | \
tr -d '"' | tr -d "'" | xargs)
CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \ CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \ sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
tr -d '"' | tr -d "'" | xargs) tr -d '"' | tr -d "'" | xargs)
@@ -377,12 +383,18 @@ for SERVICE in $SERVICES; do
fi fi
fi fi
if docker buildx build \ # Safety check: ensure no sqlite in DATABASE_URL for Docker services
--platform "$PLATFORM" \ if grep -q "DATABASE_URL.*sqlite" "$DEPLOYMENT_DIR/docker-compose.yml" 2>/dev/null; then
--file "$DOCKERFILE" \ error "Found sqlite DATABASE_URL in docker-compose.yml - SQLite not allowed in Docker"
--tag "$IMAGE_TAG" \ exit 1
--load \ fi
"$BUILD_CONTEXT"; then
BUILD_ARGS="--platform \"$PLATFORM\" --file \"$DOCKERFILE\" --tag \"$IMAGE_TAG\" --load"
if [ -n "$TARGET" ]; then
BUILD_ARGS="$BUILD_ARGS --target \"$TARGET\""
fi
if docker buildx build $BUILD_ARGS "$BUILD_CONTEXT"; then
echo -e " ${GREEN}${NC} Built ${CYAN}$SERVICE${NC}" echo -e " ${GREEN}${NC} Built ${CYAN}$SERVICE${NC}"
BUILT_IMAGES+=("$IMAGE_TAG") BUILT_IMAGES+=("$IMAGE_TAG")
echo "" echo ""
@@ -407,89 +419,15 @@ if ! docker pussh --help > /dev/null 2>&1; then
echo " Install: npm run install:pussh" echo " Install: npm run install:pussh"
fi fi
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags) # Detect images
cd "$DEPLOYMENT_DIR" IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true))
COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true)
IMAGES=()
for S in $COMPOSE_SERVICES; do
# Try to read explicit image: field from the compose config for this service
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
grep -A5 "^[[:space:]]*${S}:" | \
grep -m1 "image:" || true)
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
IMAGES+=("$IMAGE_FROM_COMPOSE")
else
# If service has a build section (we built it above), use the tag pattern used during build
TAG="${PROJECT_NAME}-${S}:latest"
# Only include the tag if the image exists locally (avoid pushing unrelated images)
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then
IMAGES+=("$TAG")
fi
fi
done
# Deduplicate while preserving order
if [ ${#IMAGES[@]} -gt 0 ]; then
IMAGES=($(printf "%s\n" "${IMAGES[@]}" | awk '!seen[$0]++'))
fi
# Verify that all built images are among the detected images to be pushed.
# This prevents accidentally pushing unrelated images.
BUILT_COUNT=${#BUILT_IMAGES[@]}
MATCHING_BUILT=0
MISSING_FROM_DETECTED=()
for BI in "${BUILT_IMAGES[@]}"; do
found=false
for DI in "${IMAGES[@]}"; do
if [ "$BI" = "$DI" ]; then
found=true
break
fi
done
if [ "$found" = true ]; then
MATCHING_BUILT=$((MATCHING_BUILT + 1))
else
MISSING_FROM_DETECTED+=("$BI")
fi
done
# Also list detected images that weren't built locally (these are likely external images)
NOT_BUILT_DETECTED=()
for DI in "${IMAGES[@]}"; do
built=false
for BI in "${BUILT_IMAGES[@]}"; do
if [ "$DI" = "$BI" ]; then
built=true
break
fi
done
if [ "$built" = false ]; then
NOT_BUILT_DETECTED+=("$DI")
fi
done
if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then
error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})."
if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then
echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}"
fi
if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then
echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}"
fi
echo "Aborting to avoid pushing incorrect images."
exit 1
fi
if [ ${#IMAGES[@]} -eq 0 ]; then if [ ${#IMAGES[@]} -eq 0 ]; then
error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}" error "No ${PROJECT_NAME} images found"
fi fi
# Pre-pull unregistry image if needed # Pre-pull unregistry image if needed
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry" UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry:0.3.1"
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
substep "Pulling unregistry image (one-time setup)..." substep "Pulling unregistry image (one-time setup)..."
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
+2 -1
View File
@@ -5,10 +5,11 @@ echo > deployment/.env
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env ./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
cat >> deployment/.env <<EOF cat >> deployment/.env <<EOF
VAPID_SUBJECT=mailto:support@fromchat.ru
JWT_SECRET="$(openssl rand -base64 32)" JWT_SECRET="$(openssl rand -base64 32)"
TURN_USERNAME=<set> TURN_USERNAME=<set>
TURN_SECRET=<set> TURN_SECRET=<set>
DEPLOYMENT_SERVER=<set> DEPLOYMENT_SERVER=<set>
FIREBASE_CERT=<set> FIREBASE_CERT=<set>
RELEASES_TOKEN=<set> DB_PASSWORD=development
EOF EOF