Compare commits
6 Commits
@@ -576,7 +576,4 @@ backend/alembic/**
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
**/*.module.scss.d.ts
|
||||
|
||||
.cursor/plans
|
||||
tmp
|
||||
compliance_keypair.txt
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
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
|
||||
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
|
||||
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
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
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.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
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.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
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
|
||||
modification follow.
|
||||
@@ -60,7 +72,7 @@ modification follow.
|
||||
|
||||
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
|
||||
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
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU 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.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
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
|
||||
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
|
||||
3 of the GNU General Public License.
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
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
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
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
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
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.
|
||||
|
||||
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
|
||||
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>
|
||||
|
||||
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
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
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/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
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
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
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,
|
||||
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/>.
|
||||
|
||||
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>.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///./data/database.db
|
||||
# sqlalchemy.url is set dynamically from DATABASE_URL environment variable
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from models import Base
|
||||
from backend.shared.models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
|
||||
@@ -1,103 +1,43 @@
|
||||
import asyncio
|
||||
import time
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
||||
import httpx
|
||||
import logging
|
||||
from models import User
|
||||
from constants import OWNER_USERNAME
|
||||
from utils import get_client_ip
|
||||
# Gateway doesn't need direct model access - it's a stateless proxy
|
||||
# Gateway doesn't need constants - it's a stateless proxy
|
||||
from backend.shared.utils import get_client_ip
|
||||
|
||||
from db import POOL_CONFIG, SessionLocal
|
||||
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from security.audit import log_access
|
||||
from security.rate_limit import limiter
|
||||
# Gateway doesn't need database access - it's a stateless proxy
|
||||
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||
from backend.security.audit import log_access
|
||||
from backend.security.rate_limit import limiter
|
||||
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")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup - run migration in subprocess to avoid logging interference
|
||||
try:
|
||||
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
|
||||
|
||||
# Gateway is a stateless proxy - no database operations or background tasks needed
|
||||
logger.info("Gateway proxy service initialized - routing to microservices")
|
||||
yield
|
||||
|
||||
# Shutdown - cancel cleanup task if it exists
|
||||
if cleanup_task:
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Gateway proxy service shutting down.")
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||
@@ -168,12 +108,87 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routes
|
||||
app.include_router(account.router)
|
||||
app.include_router(messaging.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
app.include_router(webrtc.router, prefix="/webrtc")
|
||||
app.include_router(devices.router, prefix="/devices")
|
||||
app.include_router(moderation.router)
|
||||
app.include_router(download.router)
|
||||
# Common API endpoints - route to appropriate services (defined first for priority)
|
||||
@app.api_route("/login", methods=["POST"])
|
||||
async def login(request: Request):
|
||||
"""Login endpoint - routes to account service."""
|
||||
return await _proxy_to_service("account", "login", request)
|
||||
|
||||
@app.api_route("/register", methods=["POST"])
|
||||
async def register(request: Request):
|
||||
"""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
|
||||
@@ -1,7 +1,7 @@
|
||||
from constants import *
|
||||
from db import *
|
||||
from models import *
|
||||
from validation import *
|
||||
from utils import *
|
||||
from dependencies import *
|
||||
from backend.shared.constants import *
|
||||
from backend.shared.db import *
|
||||
from backend.shared.models import *
|
||||
from backend.shared.validation import *
|
||||
from backend.shared.utils import *
|
||||
from backend.shared.dependencies import *
|
||||
from app import *
|
||||
@@ -8,7 +8,7 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine
|
||||
from constants import DATABASE_URL
|
||||
from backend.shared.constants import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,6 +19,9 @@ def run_migrations():
|
||||
This function will upgrade the database to the latest migration.
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
try:
|
||||
# FIRST: Check if database has any application tables (excluding alembic_version)
|
||||
engine = create_engine(DATABASE_URL)
|
||||
@@ -31,12 +34,11 @@ def run_migrations():
|
||||
# If no application tables exist, create them directly from models
|
||||
if not existing_tables:
|
||||
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)
|
||||
logger.info("All tables created successfully from models.")
|
||||
|
||||
# Get the directory where this script is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Note: Problematic migrations are now cleaned up during Docker build
|
||||
|
||||
# Create Alembic configuration
|
||||
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('__')]
|
||||
|
||||
# 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:
|
||||
logger.info("No migration files found. Creating initial migration...")
|
||||
# Check if database exists and has tables
|
||||
@@ -138,7 +147,11 @@ def run_migrations():
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully.")
|
||||
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...")
|
||||
# Clear the alembic_version table and start fresh
|
||||
engine = create_engine(DATABASE_URL)
|
||||
@@ -183,6 +196,7 @@ def run_migrations():
|
||||
# Try upgrade again
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
logger.info("Database migrations completed successfully after reset.")
|
||||
# Note: Index-related errors are now prevented by Docker build cleanup
|
||||
else:
|
||||
raise upgrade_error
|
||||
|
||||
@@ -289,7 +303,7 @@ def _populate_migration_file(migration_path):
|
||||
|
||||
def _generate_migration_from_models():
|
||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||
from models import Base
|
||||
from backend.shared.models import Base
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
@@ -474,10 +488,12 @@ def _get_column_type(column):
|
||||
|
||||
def _create_database_directly():
|
||||
"""Fallback method: create database directly using SQLAlchemy."""
|
||||
from models import Base
|
||||
from db import engine
|
||||
from backend.shared.models import Base
|
||||
from backend.shared.db import get_engine
|
||||
from sqlalchemy import text, inspect
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
# Check existing tables and update schema
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
@@ -533,6 +549,9 @@ def _create_database_directly():
|
||||
# Table doesn't exist, create it
|
||||
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
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||
@@ -556,9 +575,9 @@ def _create_database_directly():
|
||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||
if revision_match:
|
||||
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:
|
||||
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:
|
||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ PyJWT>=2.8.0
|
||||
fastapi[standard]>=0.116.1
|
||||
pydantic>=2.11.7
|
||||
sqlalchemy>=2.0.43
|
||||
psycopg2-binary>=2.9.9
|
||||
bcrypt>=4.3.0
|
||||
websockets>=15.0.1
|
||||
Pillow>=10.0.0
|
||||
@@ -15,3 +16,4 @@ httpx>=0.27.2
|
||||
rich>=13.9.4
|
||||
slowapi>=0.1.9
|
||||
firebase_admin>=7.1.0
|
||||
PyNaCl>=1.5.0
|
||||
@@ -8,16 +8,16 @@ import uuid
|
||||
from user_agents import parse as parse_ua
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||
from 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.constants import OWNER_USERNAME
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
|
||||
from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||
from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||
import os
|
||||
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from backend.security.audit import log_security
|
||||
from backend.security.profanity import contains_profanity
|
||||
from backend.security.rate_limit import rate_limit_per_ip
|
||||
router = APIRouter()
|
||||
|
||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||
@@ -47,16 +47,16 @@ def convert_user(user: User) -> dict:
|
||||
"id": user.id,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_seen": user.last_seen.isoformat(),
|
||||
"online": user.online,
|
||||
"online": user.is_online,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.profile_picture,
|
||||
"profile_picture": user.avatar_url,
|
||||
"bio": user.bio,
|
||||
"admin": _is_admin(user),
|
||||
"verified": user.verified,
|
||||
"suspended": user.suspended or False,
|
||||
"suspended": user.suspended,
|
||||
"suspension_reason": user.suspension_reason,
|
||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||
"deleted": user.deleted
|
||||
}
|
||||
|
||||
@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()
|
||||
|
||||
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(
|
||||
"login_failed",
|
||||
severity="warning",
|
||||
@@ -112,30 +112,9 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
detail="Неверное имя пользователя или пароль"
|
||||
)
|
||||
|
||||
# Create device session and embed into JWT
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
device_name = request.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
# Generate session ID for JWT (device session will be created on first device service access)
|
||||
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.last_seen = datetime.now()
|
||||
db.commit()
|
||||
@@ -148,15 +127,19 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
||||
for identifier in identifiers:
|
||||
_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(
|
||||
"login_success",
|
||||
username=user.username,
|
||||
user_id=user.id,
|
||||
ip=client_ip,
|
||||
session_id=session_id,
|
||||
device=device.device_type,
|
||||
os=device.os_name,
|
||||
browser=device.browser_name,
|
||||
device=device_type,
|
||||
os=ua.os.family,
|
||||
browser=ua.browser.family,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -230,8 +213,9 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
new_user = User(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
password_hash=hashed_password,
|
||||
online=True,
|
||||
hashed_password=hashed_password,
|
||||
salt="", # Not used since bcrypt includes salt in hash
|
||||
is_online=True,
|
||||
last_seen=datetime.now(),
|
||||
verified=is_owner
|
||||
)
|
||||
@@ -240,32 +224,13 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
# Create initial device session
|
||||
raw_ua = request.headers.get("user-agent")
|
||||
device_name = request.headers.get("x-device-name")
|
||||
ua = parse_ua(raw_ua or "")
|
||||
# Generate a temporary session ID for the token (device session will be created on first device service access)
|
||||
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)
|
||||
|
||||
# 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"
|
||||
if 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")
|
||||
|
||||
# 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.delete(user)
|
||||
@@ -380,14 +345,14 @@ def logout(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Revoke current session
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if payload and payload.get("session_id"):
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id == payload["session_id"],
|
||||
).update({DeviceSession.revoked: True})
|
||||
# Revoke current session - TODO: Move to device service
|
||||
# from backend.shared.utils import verify_token as _verify_token
|
||||
# payload = _verify_token(credentials.credentials)
|
||||
# if payload and payload.get("session_id"):
|
||||
# db.query(DeviceSession).filter(
|
||||
# DeviceSession.user_id == current_user.id,
|
||||
# DeviceSession.session_id == payload["session_id"],
|
||||
# ).update({DeviceSession.revoked: True})
|
||||
|
||||
current_user.online = False
|
||||
current_user.last_seen = datetime.now()
|
||||
@@ -399,7 +364,7 @@ def logout(
|
||||
username=current_user.username,
|
||||
user_id=current_user.id,
|
||||
ip=client_ip,
|
||||
session_id=payload.get("session_id") if payload else None,
|
||||
session_id=None, # TODO: Get session_id from device service
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -418,25 +383,25 @@ def change_password(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 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="Текущий пароль неверный")
|
||||
|
||||
# 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()
|
||||
|
||||
# Optionally revoke all other sessions, keeping the current one
|
||||
if password_request.logoutAllExceptCurrent:
|
||||
from utils import verify_token as _verify_token
|
||||
payload = _verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
current_session_id = payload.get("session_id")
|
||||
db.query(DeviceSession).filter(
|
||||
DeviceSession.user_id == current_user.id,
|
||||
DeviceSession.session_id != current_session_id,
|
||||
).update({DeviceSession.revoked: True})
|
||||
db.commit()
|
||||
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
|
||||
# if password_request.logoutAllExceptCurrent:
|
||||
# from backend.shared.utils import verify_token as _verify_token
|
||||
# payload = _verify_token(credentials.credentials)
|
||||
# if not payload:
|
||||
# raise HTTPException(status_code=401, detail="Invalid token")
|
||||
# current_session_id = payload.get("session_id")
|
||||
# db.query(DeviceSession).filter(
|
||||
# DeviceSession.user_id == current_user.id,
|
||||
# DeviceSession.session_id != current_session_id,
|
||||
# ).update({DeviceSession.revoked: True})
|
||||
# db.commit()
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
log_security(
|
||||
@@ -496,7 +461,7 @@ async def _delete_user_data(user: User, db: Session):
|
||||
user.deleted = True
|
||||
user.display_name = f"Deleted User #{user_id}"
|
||||
user.bio = None
|
||||
user.password_hash = ""
|
||||
user.hashed_password = ""
|
||||
user.username = f"deleted_{user_id}"
|
||||
user.profile_picture = None
|
||||
user.last_seen = None # Clear last seen timestamp
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, DeviceSession
|
||||
from utils import verify_token
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.models import User, DeviceSession
|
||||
from backend.shared.utils import verify_token
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -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
|
||||
@@ -16,29 +16,30 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from .account import convert_user
|
||||
from constants import OWNER_USERNAME
|
||||
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
||||
from push_service import push_service
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.utils import convert_user
|
||||
from backend.shared.constants import OWNER_USERNAME
|
||||
from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
||||
import os
|
||||
import httpx
|
||||
from PIL import Image
|
||||
import io
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
from better_profanity import profanity as _bp
|
||||
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from websocket.utils import authenticate_user
|
||||
from backend.security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from backend.security.profanity import contains_profanity
|
||||
from backend.security.rate_limit import rate_limit_per_ip
|
||||
from backend.services.messaging.files.websocket.utils import authenticate_user
|
||||
|
||||
from models import FcmToken
|
||||
from backend.shared.models import FcmToken
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
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_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||
|
||||
@@ -393,7 +394,16 @@ async def _send_message_internal(
|
||||
|
||||
# Send push notifications for public messages
|
||||
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:
|
||||
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()
|
||||
|
||||
# Import here to avoid circular import
|
||||
from websocket.handlers import handler_registry
|
||||
from backend.services.messaging.files.websocket.handlers import handler_registry
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -1561,3 +1571,21 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur
|
||||
raise HTTPException(500)
|
||||
|
||||
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))
|
||||
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
from constants import OWNER_USERNAME
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from security.audit import log_security
|
||||
from 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.shared.constants import OWNER_USERNAME
|
||||
from backend.shared.dependencies import get_current_user
|
||||
from backend.shared.models import User
|
||||
from backend.security.audit import log_security
|
||||
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||
from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||
|
||||
|
||||
class BlocklistUpdateRequest(BaseModel):
|
||||
|
||||
@@ -9,15 +9,16 @@ import uuid
|
||||
import io
|
||||
from fastapi import Request
|
||||
|
||||
from dependencies import get_db, get_current_user
|
||||
from models import User, UpdateBioRequest, UserProfileResponse
|
||||
from backend.shared.dependencies import get_db, get_current_user
|
||||
from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
|
||||
from pydantic import BaseModel
|
||||
from validation import is_valid_username, is_valid_display_name
|
||||
from similarity import is_user_similar_to_verified
|
||||
from .messaging import messagingManager
|
||||
from security.audit import log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from backend.shared.validation import is_valid_username, is_valid_display_name
|
||||
from backend.shared.similarity import is_user_similar_to_verified
|
||||
import os
|
||||
import httpx
|
||||
from backend.security.audit import log_security
|
||||
from backend.security.profanity import contains_profanity
|
||||
from backend.security.rate_limit import rate_limit_per_ip
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -36,7 +37,7 @@ class UpdateProfileRequest(BaseModel):
|
||||
description: str | None = None
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -479,7 +480,16 @@ async def suspend_user(
|
||||
|
||||
# Send WebSocket suspension message
|
||||
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:
|
||||
# Log error but don't fail the request
|
||||
pass
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from dependencies import get_current_user, get_db
|
||||
from models import User, PushSubscriptionRequest
|
||||
from push_service import push_service
|
||||
from pydantic import BaseModel
|
||||
from backend.shared.dependencies import get_current_user, get_db
|
||||
from backend.shared.models import User, PushSubscriptionRequest
|
||||
from backend.services.push.files import push_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class SendPublicNotificationRequest(BaseModel):
|
||||
message_id: int
|
||||
exclude_user_id: int
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe_to_push_notifications(
|
||||
request: PushSubscriptionRequest,
|
||||
@@ -44,3 +49,21 @@ async def unsubscribe_from_push_notifications(
|
||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||
except Exception as 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))
|
||||
|
||||
@@ -4,7 +4,7 @@ import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from fastapi import APIRouter, Depends
|
||||
from dependencies import get_current_user
|
||||
from backend.shared.dependencies import get_current_user
|
||||
import traceback
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from html import unescape
|
||||
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:
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
|
||||
|
||||
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)
|
||||
|
||||
_CUSTOM_RU_TERMS: Set[str] = {
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import Request
|
||||
from slowapi import Limiter
|
||||
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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
# Import them directly from websocket.handlers when needed
|
||||
@@ -6,8 +6,8 @@ from typing import Any
|
||||
from fastapi import HTTPException, WebSocket, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
from routes.messaging import (
|
||||
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
|
||||
from backend.routes.messaging import (
|
||||
MessaggingSocketManager,
|
||||
_send_message_internal,
|
||||
_edit_message_internal,
|
||||
@@ -17,7 +17,7 @@ from routes.messaging import (
|
||||
add_reaction,
|
||||
add_dm_reaction,
|
||||
)
|
||||
from models import (
|
||||
from backend.shared.models import (
|
||||
User,
|
||||
SendMessageRequest,
|
||||
EditMessageRequest,
|
||||
@@ -26,7 +26,7 @@ from models import (
|
||||
DMReactionRequest,
|
||||
UpdateLog,
|
||||
)
|
||||
from security.audit import log_access, log_dm
|
||||
from backend.security.audit import log_access, log_dm
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@@ -2,8 +2,8 @@ from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from types import SimpleNamespace
|
||||
from dependencies import get_current_user
|
||||
from models import User
|
||||
from backend.shared.dependencies import get_current_user
|
||||
from backend.shared.models import User
|
||||
|
||||
|
||||
def extract_token_from_data(data: dict) -> str | None:
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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 sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
from models import PushSubscription, User, Message, DMEnvelope
|
||||
from models import FcmToken
|
||||
from backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials as firebase_credentials
|
||||
from firebase_admin import messaging as firebase_messaging
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -0,0 +1 @@
|
||||
# Shared modules package
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
reverse_proxy 95.165.0.162:8400
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,67 +1,368 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
# Database service
|
||||
database:
|
||||
image: postgres:15
|
||||
environment:
|
||||
PORT: 8300
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||
POSTGRES_DB: fromchat
|
||||
POSTGRES_USER: fromchat_admin
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- data:/app/data
|
||||
- logs:/app/logs
|
||||
- database:/var/lib/postgresql/data
|
||||
- ./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:
|
||||
watch:
|
||||
- action: sync+restart
|
||||
path: ../backend
|
||||
target: /app
|
||||
- action: rebuild
|
||||
path: ../backend/requirements.txt
|
||||
|
||||
frontend:
|
||||
# Migration runner - runs once before other services
|
||||
migration_runner:
|
||||
build:
|
||||
dockerfile: deployment/frontend/Dockerfile
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.multi
|
||||
target: migration_runner
|
||||
environment:
|
||||
PORT: 8301
|
||||
BACKEND_HOST: http://backend:8300
|
||||
ports:
|
||||
- "8301:8301"
|
||||
DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
depends_on:
|
||||
- backend
|
||||
database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- fromchat_internal
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../frontend
|
||||
- action: sync
|
||||
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
|
||||
path: server.js
|
||||
target: /server/server.js
|
||||
- action: rebuild
|
||||
path: package.json
|
||||
path: backend/shared
|
||||
target: /app/backend/shared
|
||||
|
||||
# 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:
|
||||
build:
|
||||
context: ./caddy
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- certs:/root/site/certs
|
||||
networks:
|
||||
- fromchat_external
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
XDG_DATA_HOME: /root/site/certs
|
||||
XDG_CONFIG_HOME: /root/site/certs
|
||||
- XDG_DATA_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:
|
||||
data:
|
||||
name: fromchat-data
|
||||
logs:
|
||||
name: fromchat-logs
|
||||
database:
|
||||
name: fromchat-database
|
||||
certs:
|
||||
name: fromchat-certs
|
||||
logs:
|
||||
name: fromchat-logs
|
||||
data:
|
||||
name: fromchat-data
|
||||
|
||||
networks:
|
||||
fromchat_internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
fromchat_external:
|
||||
driver: bridge
|
||||
@@ -1,28 +1,44 @@
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { resolve } from 'path';
|
||||
|
||||
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 filePath = process.env.STATIC_FILE_PATH || ".";
|
||||
|
||||
// API proxy middleware
|
||||
app.use('/api', createProxyMiddleware({
|
||||
// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
|
||||
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,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
ws: true
|
||||
}));
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(resolve(filePath)));
|
||||
|
||||
// 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'));
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server launched on http://localhost:${port}`);
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`Backend host: ${backendHost}`);
|
||||
console.log(`Server launched on http://0.0.0.0:${port}`);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 879 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 231 KiB |
@@ -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 |
@@ -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 |
@@ -1,5 +1,5 @@
|
||||
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 { motion, AnimatePresence } from "motion/react";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
@@ -8,23 +8,18 @@ import { RegisterForm } from "./RegisterForm";
|
||||
import type { Variants, Transition } from "motion/react";
|
||||
import styles from "./auth.module.scss";
|
||||
|
||||
const MIN_HEIGHT = 400;
|
||||
|
||||
const slideVariants: Variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 300 : -300,
|
||||
opacity: 0,
|
||||
y: 0 // Ensure no vertical movement
|
||||
opacity: 0
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
y: 0 // Ensure no vertical movement
|
||||
opacity: 1
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction > 0 ? -300 : 300,
|
||||
opacity: 0,
|
||||
y: 0 // Ensure no vertical movement
|
||||
opacity: 0
|
||||
})
|
||||
};
|
||||
|
||||
@@ -50,55 +45,80 @@ export default function AuthPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loginFormRef = useRef<HTMLDivElement>(null);
|
||||
const registerFormRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState<number>(400);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState<number | "auto">("auto");
|
||||
const currentMode = searchParams.get("mode") || "login";
|
||||
const enteringElementRef = useRef<"login" | "register" | null>(null);
|
||||
const [effectActivated, setEffectActivated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevMode.current !== currentMode) {
|
||||
setDirection(currentMode === "register" ? 1 : -1);
|
||||
prevMode.current = currentMode;
|
||||
setIsTransitioning(true);
|
||||
enteringElementRef.current = currentMode as "login" | "register";
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
// Setup ResizeObserver to watch for content changes
|
||||
useEffect(() => {
|
||||
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
|
||||
|
||||
if (activeRef.current) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const height = entry.contentRect.height;
|
||||
const measureActiveHeight = useCallback(() => {
|
||||
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
|
||||
if (activeComponent) {
|
||||
const height = activeComponent.scrollHeight;
|
||||
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 () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
cancelAnimationFrame(rafId1);
|
||||
if (rafId2 !== null) {
|
||||
cancelAnimationFrame(rafId2);
|
||||
}
|
||||
};
|
||||
}, [currentMode]);
|
||||
|
||||
|
||||
function switchMode(newMode: "login" | "register") {
|
||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||
}
|
||||
|
||||
function handleAnimationComplete() {
|
||||
setIsTransitioning(false);
|
||||
}
|
||||
function handleAnimationComplete(
|
||||
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 (
|
||||
<AuthContainer>
|
||||
@@ -107,8 +127,14 @@ export default function AuthPage() {
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: `${containerHeight}px`,
|
||||
transition: isTransitioning ? "height 0.3s ease" : "none"
|
||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
||||
transition: "height 0.3s ease"
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
setContainerHeight("auto");
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="sync" custom={direction}>
|
||||
@@ -122,8 +148,11 @@ export default function AuthPage() {
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
||||
className={styles.formWrapper}
|
||||
style={{
|
||||
position: containerHeight === "auto" ? "relative" : "absolute"
|
||||
}}
|
||||
>
|
||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||
</motion.div>
|
||||
@@ -137,7 +166,7 @@ export default function AuthPage() {
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
||||
className={styles.formWrapper}
|
||||
>
|
||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||
|
||||
@@ -98,12 +98,6 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
&.relative {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.authHeader {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
import styles from "./download-app.module.scss";
|
||||
|
||||
export default function DownloadAppPage() {
|
||||
return (
|
||||
<div className={styles.downloadAppScreen}>
|
||||
<div className={styles.downloadAppCard}>
|
||||
<h1>Скачайте приложение</h1>
|
||||
<div>
|
||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
||||
<p>
|
||||
Этот сайт не предназначен для работы на маленьких экранах.
|
||||
Выберите вашу платформу:
|
||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
||||
вам нужно скачать приложение мессенджера.
|
||||
</p>
|
||||
<div className={styles.downloadAppButtons}>
|
||||
<a href="/download?os=android" className={styles.downloadAppBtn}>
|
||||
<MaterialIcon name="android" />
|
||||
Android
|
||||
|
||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
||||
<MaterialButton>Скачать на GitHub</MaterialButton>
|
||||
</a>
|
||||
<a href="/download?os=ios" className={styles.downloadAppBtn}>
|
||||
<MaterialIcon name="phone_iphone" />
|
||||
iOS
|
||||
|
||||
<p>
|
||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/denis0001-dev">
|
||||
<MaterialButton>Написать в поддержку</MaterialButton>
|
||||
</a>
|
||||
</div>
|
||||
<p>
|
||||
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,9 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.downloadAppScreen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
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;
|
||||
}
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
Если появится запрос "Разрешить установку из неизвестных источников" — дайте
|
||||
разрешение для браузера, из которого вы скачивали APK.
|
||||
</li>
|
||||
<li>
|
||||
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
|
||||
нажмите "Подробнее" → "Всё равно установить" (или аналогичную кнопку).
|
||||
</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 для
|
||||
<span className={styles.osName}>{osInfo.label}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderInstructions(os)}
|
||||
</div>
|
||||
</StyledDialog>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,236 +1,228 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import styles from "@/pages/home/home.module.scss";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import styles from "./home.module.scss";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } 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";
|
||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||
|
||||
interface FeatureSectionProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
screenshot: string;
|
||||
right?: boolean;
|
||||
function GitHubLink({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureSection({
|
||||
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>
|
||||
)
|
||||
|
||||
function SupportLink({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={`${styles.featureContainer}`}>
|
||||
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>}
|
||||
</div>
|
||||
)
|
||||
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
const { isMobile } = useDownloadAppScreen();
|
||||
const isLoggedIn = user.authToken && user.currentUser;
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const downloadSectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const scrollToDownload = () => {
|
||||
const section = downloadSectionRef.current;
|
||||
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;
|
||||
function handleGetStarted() {
|
||||
if (isMobile) {
|
||||
navigate("/download-app");
|
||||
} else if (isLoggedIn) {
|
||||
navigate("/chat");
|
||||
} else {
|
||||
navigate("/login");
|
||||
}
|
||||
}
|
||||
|
||||
setDialogOs(os);
|
||||
setDialogOpen(true);
|
||||
|
||||
const link = document.createElement("a");
|
||||
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";
|
||||
};
|
||||
const openBtn = (
|
||||
<MaterialButton variant="filled" onClick={handleGetStarted}>
|
||||
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
|
||||
</MaterialButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<section className={styles.title}>
|
||||
<div className={styles.titleLogoWrapper}>
|
||||
<div className={styles.titleLogo} />
|
||||
</div>
|
||||
<div className={styles.titleContent}>FromChat</div>
|
||||
<div className={styles.titleDesc}>
|
||||
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
|
||||
</div>
|
||||
<div className={styles.titleButtons}>
|
||||
{isMobile ? null : (
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.heroContent}>
|
||||
<h2 className={styles.heroTitle}>
|
||||
Безопасный мессенджер с открытым исходным кодом
|
||||
</h2>
|
||||
<p className={styles.heroDescription}>
|
||||
FromChat — это полностью открытый мессенджер с end-to-end шифрованием,
|
||||
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
|
||||
</p>
|
||||
<div className={styles.heroActions}>
|
||||
{openBtn}
|
||||
{!isMobile && (
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
onClick={() => navigate("/auth?mode=login")}
|
||||
icon="devices"
|
||||
>
|
||||
Открыть веб-версию
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрироваться
|
||||
</MaterialButton>
|
||||
)}
|
||||
<SplitButton
|
||||
variant={isMobile ? "filled" : "tonal"}
|
||||
text="Скачать приложение"
|
||||
icon="download"
|
||||
onPrimaryClick={() => triggerDownload(detectOs())}
|
||||
menuOpen={menuOpen}
|
||||
onMenuOpen={setMenuOpen}
|
||||
menu={(
|
||||
<MaterialList>
|
||||
{ALL_OS.map((os) => (
|
||||
<MaterialListItem
|
||||
key={os}
|
||||
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
|
||||
headline={OS_CONFIG[os].label}
|
||||
rounded
|
||||
onClick={() => {
|
||||
if (triggerDownload(os)) setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{["windows", "linux", "macos"].includes(os) && (
|
||||
<span
|
||||
slot="icon"
|
||||
className={styles.menuCustomIcon}
|
||||
style={{
|
||||
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
</MaterialListItem>
|
||||
))}
|
||||
</MaterialList>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroVisual}>
|
||||
<div className={styles.chatPreview}>
|
||||
<div className={styles.chatWindow}>
|
||||
<div className={styles.chatHeader}>
|
||||
<div className={styles.chatTitle}>Общий чат</div>
|
||||
<div className={styles.onlineIndicator}>●</div>
|
||||
</div>
|
||||
<div className={styles.chatMessages}>
|
||||
<div className={`${styles.message} ${styles.received}`}>
|
||||
<div className={styles.messageAvatar}>А</div>
|
||||
<div className={styles.messageContent}>
|
||||
<div className={styles.messageText}>Привет! Как дела?</div>
|
||||
<div className={styles.messageTime}>14:30</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.message} ${styles.sent}`}>
|
||||
<div className={styles.messageContent}>
|
||||
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
|
||||
<div className={styles.messageTime}>14:32</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.message} ${styles.received}`}>
|
||||
<div className={styles.messageAvatar}>Б</div>
|
||||
<div className={styles.messageContent}>
|
||||
<div className={styles.messageText}>Отправляю файл 📎</div>
|
||||
<div className={styles.messageTime}>14:35</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.features}>
|
||||
<FeatureSection
|
||||
title={<>Общий чат</>}
|
||||
screenshot={generalChatScreenshot}
|
||||
right
|
||||
>
|
||||
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
|
||||
</FeatureSection>
|
||||
<FeatureSection
|
||||
title={<>Личные сообщения</>}
|
||||
screenshot={dmScreenshot}>
|
||||
Общайтесь с одним человеком в личной переписке.
|
||||
</FeatureSection>
|
||||
<div className={styles.container}>
|
||||
<h3 className={styles.sectionTitle}>Возможности</h3>
|
||||
<div className={styles.featuresGrid}>
|
||||
<div className={styles.featureCard}>
|
||||
<div className={styles.featureIcon}>
|
||||
<MaterialIcon name="security" />
|
||||
</div>
|
||||
<h4>End-to-End Шифрование</h4>
|
||||
<p>
|
||||
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
|
||||
Только вы и получатель можете прочитать сообщения.
|
||||
</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 ref={downloadSectionRef} className={styles.download}>
|
||||
<section className={styles.download}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.downloadContent}>
|
||||
<h3>Скачайте приложение</h3>
|
||||
<p>
|
||||
Настольное приложение с уведомлениями и автономной работой
|
||||
или мобильное приложение для Android и iOS.
|
||||
Для лучшего опыта используйте настольное приложение с поддержкой
|
||||
уведомлений и автономной работы.
|
||||
</p>
|
||||
<table className={styles.downloadTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Платформа</th>
|
||||
<th>Описание</th>
|
||||
<th />
|
||||
</tr>
|
||||
</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)}
|
||||
<div className={styles.downloadButtons}>
|
||||
{!isMobile ? (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Скачать
|
||||
<MaterialButton variant="filled">
|
||||
<MaterialIcon name="download" slot="icon" />
|
||||
Скачать для ПК
|
||||
</MaterialButton>
|
||||
<MaterialIconButton
|
||||
variant={getButtonVariant(os)}
|
||||
icon="download"
|
||||
className={styles.downloadButtonIcon}
|
||||
onClick={() => triggerDownload(os)}
|
||||
title={`Скачать ${OS_CONFIG[os].label}`}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</a>
|
||||
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
|
||||
<MaterialIcon name="language" slot="icon" />
|
||||
Веб-версия
|
||||
</MaterialButton>
|
||||
</>
|
||||
) : (
|
||||
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
|
||||
Скачать приложение
|
||||
</MaterialButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -240,8 +232,7 @@ export default function HomePage() {
|
||||
<div className={styles.ctaContent}>
|
||||
<h3>Готовы начать общение?</h3>
|
||||
<p>
|
||||
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
|
||||
или звоните — всё бесплатно и с открытым кодом.
|
||||
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
|
||||
</p>
|
||||
<div className={styles.ctaActions}>
|
||||
{isMobile ? (
|
||||
@@ -268,8 +259,24 @@ export default function HomePage() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
|
||||
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||
<footer className={styles.homepageFooter}>
|
||||
<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>© 2025 FromChat. Сделано программистом denis0001-dev с ❤️ для свободы общения.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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";
|
||||
}
|
||||
@@ -17,21 +17,18 @@ import 'mdui/components/button';
|
||||
import 'mdui/components/text-field';
|
||||
import 'mdui/components/button-icon';
|
||||
import 'mdui/components/switch';
|
||||
import 'mdui/components/ripple';
|
||||
import 'mdui/components/chip';
|
||||
import 'mdui/components/badge';
|
||||
import "mdui/mdui.css";
|
||||
import 'mdui/components/circular-progress';
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
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 { Switch } from 'mdui/components/switch';
|
||||
import type { Override } from '@/core/types';
|
||||
import type { Button } from 'mdui/components/button';
|
||||
import type { ButtonIcon } from 'mdui/components/button-icon';
|
||||
import type { Ripple } from 'mdui/components/ripple';
|
||||
import type { Icon } from 'mdui/components/icon';
|
||||
import type { Fab } from 'mdui/components/fab';
|
||||
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 MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
||||
export type MDUIIcon = Override<HTMLElement, Icon>;
|
||||
export type MDUIRipple = Override<HTMLElement, Ripple>;
|
||||
export type MDUIFab = Override<HTMLElement, Fab>;
|
||||
export type MDUITabs = Override<HTMLElement, Tabs>;
|
||||
export type MDUITab = Override<HTMLElement, Tab>;
|
||||
@@ -145,68 +141,3 @@ export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-b
|
||||
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
||||
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 };
|
||||
}
|
||||
@@ -8,12 +8,6 @@ declare global {
|
||||
interface SyntheticEvent<T = Element, E = Event> {
|
||||
target: EventTarget & T;
|
||||
}
|
||||
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Augment DOM event listeners to provide typed target for ALL elements
|
||||
|
||||
@@ -314,6 +314,8 @@ step "Detecting services"
|
||||
cd "$DEPLOYMENT_DIR"
|
||||
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
|
||||
error "No services found in docker-compose.yml"
|
||||
fi
|
||||
@@ -341,6 +343,10 @@ for SERVICE in $SERVICES; do
|
||||
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
|
||||
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:" | \
|
||||
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
|
||||
tr -d '"' | tr -d "'" | xargs)
|
||||
@@ -377,12 +383,18 @@ for SERVICE in $SERVICES; do
|
||||
fi
|
||||
fi
|
||||
|
||||
if docker buildx build \
|
||||
--platform "$PLATFORM" \
|
||||
--file "$DOCKERFILE" \
|
||||
--tag "$IMAGE_TAG" \
|
||||
--load \
|
||||
"$BUILD_CONTEXT"; then
|
||||
# Safety check: ensure no sqlite in DATABASE_URL for Docker services
|
||||
if grep -q "DATABASE_URL.*sqlite" "$DEPLOYMENT_DIR/docker-compose.yml" 2>/dev/null; then
|
||||
error "Found sqlite DATABASE_URL in docker-compose.yml - SQLite not allowed in Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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}"
|
||||
BUILT_IMAGES+=("$IMAGE_TAG")
|
||||
echo ""
|
||||
@@ -407,89 +419,15 @@ if ! docker pussh --help > /dev/null 2>&1; then
|
||||
echo " Install: npm run install:pussh"
|
||||
fi
|
||||
|
||||
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
|
||||
cd "$DEPLOYMENT_DIR"
|
||||
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
|
||||
# Detect images
|
||||
IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true))
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
substep "Pulling unregistry image (one-time setup)..."
|
||||
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
|
||||
|
||||
@@ -5,10 +5,11 @@ echo > deployment/.env
|
||||
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
|
||||
|
||||
cat >> deployment/.env <<EOF
|
||||
VAPID_SUBJECT=mailto:support@fromchat.ru
|
||||
JWT_SECRET="$(openssl rand -base64 32)"
|
||||
TURN_USERNAME=<set>
|
||||
TURN_SECRET=<set>
|
||||
DEPLOYMENT_SERVER=<set>
|
||||
FIREBASE_CERT=<set>
|
||||
RELEASES_TOKEN=<set>
|
||||
DB_PASSWORD=development
|
||||
EOF
|
||||
|
||||