import os import io import base64 import json import logging import threading import time from datetime import datetime, timedelta import random import string from flask import Flask, render_template_string, request, redirect, url_for, flash, make_response, jsonify, session, send_file from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError from dotenv import load_dotenv import requests load_dotenv() app = Flask(__name__) app.secret_key = 'your_unique_secret_key_gippo_312_shop_54321_no_login_synkris' DATA_FILE = 'data.json' DATA_FILE_TEMP = 'data.json.tmp' PROMPTS_FILE = 'prompts.json' SYNC_FILES = [DATA_FILE, PROMPTS_FILE] REPO_ID = "Kgshop/synkriseu" HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") DOWNLOAD_RETRIES = 3 DOWNLOAD_DELAY = 5 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') data_lock = threading.Lock() def setup_initial_files(): if not os.path.exists(PROMPTS_FILE): prompts_data = { "base_prompts": { "model_base": """**PRIMARY GOAL: A BELIEVABLE HIGH-END COMMERCIAL FASHION PHOTOGRAPH THAT COULD HAVE BEEN CAPTURED ON A REAL MEDIUM-FORMAT CAMERA. PHOTOREALISM AND 10,000,000% REFERENCE FIDELITY OVERRIDE ALL STYLIZATION. NO GENERATED-IMAGE LOOK.** **REFERENCE ROLES:** Treat the uploaded clothing image as the immutable 10,000,000% absolute ground truth for the garment. Do not copy its background. Build the selected scene as a new, physically plausible location. 1. **GARMENT DIGITAL TWIN — 10,000,000% ABSOLUTE FIDELITY OVERRIDE (HIGHEST VISUAL PRIORITY):** Reproduce only what is visibly supported by the clothing reference with absolute 100% pixel-perfect digital twin accuracy across ALL categories: - **HEADWEAR & HATS (шапки, кепки, панамы, береты, балаклавы, капюшоны):** Exact knit gauge/ribbing, brim shape, visor curvature, crown panel stitching, eyelets, strapback/snapback closures, embroidered logos, woven patches, beanie fold height, peak shape, and realistic fit on head without distortion. - **TOPS & SHIRTS (рубашки, футболки, поло, худи, свитшоты, блузки, топы):** Exact collar shape (button-down, spread, Cuban, classic, mandarin, ribbed crewneck, V-neck, hood construction with grommets and drawstrings with aglets), plackets, button count, button material, pocket placement, sleeve cuffs, hemline, exact graphic prints, screen print placement and scale, embroidery, distressing, typography, and fabric weave (oxford, poplin, twill, jersey, fleece, silk, linen). - **JACKETS & OUTERWEAR (куртки, пуховики, пальто, ветровки, бомберы, блейзеры, парки):** Exact baffle width/quilting geometry, heavy zipper gauge, custom pull tabs, storm flaps, snaps, buttons, pocket flap styling, lapel notch/peak shape, shoulder structure, drawstrings, toggles, lining peek, hardware sheen (matte black, antique brass, chrome, silver), material thickness, technical water-repellent luster, leather grain, shearling, corduroy, fleece, down loft and physical weight. - **PANTS & BOTTOMS (брюки, джинсы, карго, шорты, юбки):** Exact waistband construction, belt loops, fly stitching, pocket rivets, distressing, whiskering, wash gradients, side seams, cuff break, drape, and volume. - **FOOTWEAR & SHOES (кроссовки, ботинки, туфли, кеды, лоферы, сапоги, сандалии):** Exact sole geometry, outsole tread pattern, midsole thickness, color blocking, eyelet configuration, lace thickness and lacing pattern, upper material panels (suede, full-grain leather, mesh, knit, TPU overlays), heel tab, branding badges, toe-box shape, stitching density, and realistic floor contact. - **ACCESSORIES & JEWELRY (сумки, ремни, часы, очки, шарфы, украшения):** Exact buckle mechanism, strap width, chain link geometry, stone settings, clasp, logo engraving, hardware finish, and material density. - **COLOR & GRAPHICS:** 100% Pantone/hex shade match, zero color shifting, pattern scale (plaid, stripes, florals, monogram, camo), no hallucinated graphics, no missing or blurred branding. - **PHYSICAL MICRO-TEXTURES & BEHAVIOR:** Exact weave density, thread thickness, nap, pile, sheen, stiffness, drape, tension folds, compression wrinkles, natural gravity. - **STRICT PROHIBITION:** Absolutely NO restyling, redesigning, simplifying, smoothing out, mirroring, recoloring, replacing buttons/zippers, or inventing elements. The clothing reference is the immutable absolute ground truth. Keep the garment unobstructed whenever composition permits. 2. **REAL HUMAN PHOTOGRAPHY:** Create a plausible real person, not an idealized avatar. Preserve natural facial asymmetry, pores, fine lines, peach fuzz, tiny blemishes, subsurface skin color variation, individual hair strands and realistic wetness only where physically appropriate. Eyes have consistent gaze, natural sclera and catchlights from actual scene lights. Teeth, ears, hands, nails and joints are anatomically coherent. No wax, porcelain, plastic, excessive retouching or beauty-filter skin. 3. **ANATOMY, SELECTED PHYSIQUE AND CONTACT:** One coherent person with normal limb count, five distinct fingers per visible hand, correct joints and believable weight distribution. Make every selected physique and appearance setting strongly, immediately and accurately visible; do not average it toward a generic body. Clothing follows the selected body through natural fit, tension and drape without changing any garment design detail or fusing into skin. Hands do not merge with garments or props. Feet contact the ground; shadows and reflections agree with that contact. 4. **PHYSICALLY GROUNDED BACKGROUND:** The environment must look photographed, not rendered: coherent architecture and perspective, real material wear, small irregularities, plausible object scale, non-repeating textures, natural depth layers and scene-specific lived-in detail. Match subject and environment in lens perspective, horizon, depth of field, exposure, white balance, light direction, softness, color spill, occlusion, contact shadows, reflections and atmospheric haze. No synthetic bokeh, wallpaper repetition, melted geometry or floating objects. 5. **CAMERA REALISM:** Professional full-frame or medium-format capture, realistic lens behavior, restrained dynamic range, fine sensor/film texture, natural edge falloff and optical depth of field. Sharp where the lens is focused; never uniformly oversharpened. Avoid fake HDR, excessive clarity and sterile CGI perfection. 6. **COMMERCIAL SAFETY:** Fully and appropriately clothed professional fashion image. No unintended text; preserve only reference-supported garment branding.""", "model_base_own_model": """**PRIMARY GOAL: A BELIEVABLE REAL-CAMERA FASHION PHOTOGRAPH WITH MAXIMUM PERSON AND GARMENT REFERENCE FIDELITY (10,000,000% ACCURACY). NO GENERATED-IMAGE LOOK.** **REFERENCE ROLES:** The MODEL image is the sole identity/anatomy reference. The CLOTHING image is the sole garment reference. Do not copy either source background; construct the selected scene as a new physically plausible location. 1. **IDENTITY LOCK:** Preserve the person's recognizable facial geometry, age, skin tone, natural asymmetry, hairline, hair texture, body proportions and visible identifying traits. Do not beautify, age-shift, face-swap imperfectly or change ethnicity. Retain pores, peach fuzz, tiny blemishes and realistic skin color variation; no waxy skin or excessive retouching. 2. **GARMENT DIGITAL TWIN — 10,000,000% ABSOLUTE FIDELITY OVERRIDE:** Transfer the exact silhouette, cut, panel geometry, seams, stitch density, hems, cuffs, pockets, closures, hardware, trims, prints, embroidery, distressing, labels, logo placement, colors and pattern scale from the clothing reference with 10,000,000% fidelity. Full digital twin accuracy across all hats, shirts, jackets, shoes, pants, and accessories. Match weave, grain, pile, thickness, opacity, gloss, weight, drape, tension and folds. Never add, remove, mirror, reinterpret or simplify a garment feature. 3. **FIT AND OCCLUSION:** Fit the garment naturally to the referenced body while preserving its design proportions. Ensure correct layering, openings and fabric-body contact. Keep important garment features visible and prevent hair, hands or props from unnecessarily hiding them. 4. **HUMAN, SELECTED PHYSIQUE AND ANATOMY REALISM:** Natural face, eyes, teeth, ears, hands, nails and joints; five distinct fingers per visible hand; believable posture and weight. Every selected body and appearance attribute must be strongly and clearly visible rather than generic or subtle. Preserve the garment unchanged and express the physique only through correct anatomy and natural drape. No duplicated body parts, fused fingers, warped limbs or mannequin stiffness. 5. **PHYSICALLY GROUNDED BACKGROUND:** Real-world scale, coherent perspective and architecture, non-repeating textures, material wear and subtle irregularity. Subject and scene must share one lens, horizon, exposure, white balance, light direction, shadow softness, reflections, color spill, atmospheric depth and contact shadows. No compositing halo, synthetic bokeh, CGI surfaces or floating elements. 6. **CAMERA REALISM:** Restrained professional color grade, natural dynamic range, plausible depth of field and fine sensor/film texture. No fake HDR, plastic smoothing or uniform oversharpening.""", "object_base": """**PRIMARY GOAL: A BELIEVABLE COMMERCIAL PRODUCT PHOTOGRAPH, NOT A CGI RENDER. MAXIMUM 10,000,000% REFERENCE FIDELITY.** 1. **PRODUCT DIGITAL TWIN — 10,000,000% PRIORITY:** Use the product/garment reference as the sole immutable source of truth. Preserve exact proportions, silhouette, construction, edges, openings, components, surface finish, color, graphics, labels and logo placement across all products, clothing items (hats, shirts, jackets, shoes, trousers), and accessories. Reproduce material microtexture, machining, weave, grain, gloss, translucency, wear and manufacturing tolerances. Never invent, remove, mirror or redesign features. 2. **REAL SCALE AND OPTICS:** Maintain plausible physical dimensions and camera perspective. Use realistic lens behavior, depth of field, reflections and highlight roll-off. Avoid perfect computer-generated bevels, impossible reflections and uniformly sharp surfaces. 3. **PHYSICALLY GROUNDED SET:** Build a real photographable set with coherent geometry, gravity, contact shadows, surface compression, reflections, light direction and color spill. Include subtle material imperfections and non-repeating texture without making the product dirty. No floating unless explicitly selected; if selected, support the illusion with credible commercial rigging aesthetics. 4. **LIGHTING:** Professional but physically plausible key, fill and edge separation. Highlights reveal true material rather than turning it into plastic. Restrained grade, natural dynamic range, no fake HDR. 5. **NEGATIVE CONSTRAINTS:** No warped geometry, duplicate parts, malformed branding, random text, melted edges, texture tiling, halos, cutout look or generated-image artifacts.""", "children_base": """**PRIMARY GOAL: A SAFE, AGE-APPROPRIATE, BELIEVABLE CHILDREN'S FASHION PHOTOGRAPH CAPTURED IN A REAL LOCATION. 10,000,000% GARMENT FIDELITY. NO DOLL OR GENERATED-IMAGE LOOK.** 1. **NATURAL CHILD:** Authentic age, proportions, posture and expression. Real skin with pores, subtle flush, freckles or tiny imperfections where natural; fine hair strands and consistent eyes. No adult styling, beauty-filter skin, wax, plastic or porcelain appearance. 2. **GARMENT DIGITAL TWIN — 10,000,000% PRIORITY:** Preserve exact cut, silhouette, panel geometry, seams, stitching, hems, cuffs, pockets, closures, hardware, trims, prints, labels, colors and pattern scale from the clothing reference with 100% digital twin accuracy across headwear, shirts, outerwear, pants, and footwear. Match weave, thickness, softness, weight, drape and movement. Never add, remove, mirror or redesign details. 3. **ANATOMY AND MOTION:** Normal anatomy, five distinct fingers per visible hand, correct joints, believable balance and age-appropriate movement. Clothing moves with the body and never fuses with skin. 4. **REAL BACKGROUND INTEGRATION:** Coherent real-world scale, perspective, architecture, texture variation, gravity, light direction, contact shadows, reflections and atmospheric depth. Subject and scene share one camera and one lighting setup. No repeated textures, synthetic bokeh, melted objects, compositing halos or floating props. 5. **CAMERA REALISM:** Natural dynamic range, restrained color, plausible depth of field and fine sensor/film texture. Professional yet candid, never sterile CGI perfection.""", "cosmetics_base": """**PRIMARY GOAL: A BELIEVABLE REAL-CAMERA BEAUTY CAMPAIGN WITH EXACT PRODUCT AND SHADE FIDELITY (10,000,000% ACCURACY). NO CGI OR BEAUTY-FILTER LOOK.** 1. **PRODUCT DIGITAL TWIN — 10,000,000% PRIORITY:** Preserve package dimensions, cap and applicator geometry, materials, finish, color, graphics and reference-supported branding. Reproduce the cosmetic's exact hue, undertone, opacity, viscosity, particle size, sheen and coverage. No invented labels or altered packaging. 2. **REAL SKIN:** Visible pores, vellus hair, fine lines, natural asymmetry and subtle tonal variation. Retouch only as in premium photography while retaining texture. No poreless wax, plastic shine, glass skin, painted eyes or overly white sclera. 3. **APPLICATION TRUTH:** Apply the product only where appropriate and show its true finish under neutral, color-accurate light. Preserve skin texture beneath makeup. No color shift, excessive saturation, impossible gloss or product floating above skin. 4. **ANATOMY:** Coherent face, lips, teeth, ears, hands and nails; five distinct fingers per visible hand. Correct grip and product contact, no fused fingers or duplicated features. 5. **REAL SET AND LIGHT:** Physically plausible beauty lighting, reflections, shadows, depth of field and background materials. Match product, model and set in perspective, white balance, exposure and color spill. No halos, synthetic bokeh, repeated textures, fake HDR or rendered surfaces.""", "marketplace_mode_base": """ **MARKETPLACE FLAGSHIP HERO-SHOT & DESIGNER INFOGRAPHICS (WILDBERRIES / OZON / LAMODA / AMAZON PRO EDITION):** - **FLAGSHIP COMMERCIAL PRESENTATION:** Create a high-converting, premium designer hero cover-shot for leading e-commerce platforms (Wildberries, Ozon, Lamoda, Amazon). The product/model is dominant, impeccably framed, and presented with high visual impact and pristine commercial clarity. - **DESIGNER INFOGRAPHIC OVERLAYS & BENEFIT CARDS:** - Modern, elegant typography and minimalist floating infographic elements arranged with graphic-design precision in the negative space. - Stylish benefit badges, frosted glass (glassmorphism) translucent pills, and feature callouts highlighting key selling points (e.g., "ПРЕМИУМ КАЧЕСТВО / PREMIUM QUALITY", "100% НАТУРАЛЬНЫЙ ХЛОПОК / 100% COTTON", "ИДЕАЛЬНАЯ ПОСАДКА / PERFECT FIT", "ВОДООТТАЛКИВАЮЩАЯ ТКАНЬ / WATERPROOF", "АНАТОМИЧЕСКАЯ ПОДОШВА / ANATOMIC SOLE", "ДЫШАЩИЙ МАТЕРИАЛ / BREATHABLE", "НОВАЯ КОЛЛЕКЦИЯ / NEW COLLECTION", "ПРЕМИАЛЬНАЯ ФУРНИТУРА / HIGH DURABILITY"). - Clean specification tags, fabric composition markers, and modern vector-style icons integrated seamlessly into the layout. - Floating aesthetic circular/rounded detail zoom-in callout showcasing ultra-close-up microtexture of the fabric/material/hardware. - **FLAGSHIP STUDIO LIGHTING & SET:** - Professional commercial studio cyclorama, luxury minimalist architectural set, or soft neutral pedestal with smooth tonal gradient. - 3-point studio lighting (octabox soft key, clean fill, sharp crisp edge/rim separation) creating rich depth, realistic soft contact shadows, and vibrant color pop. - **PRODUCT FIDELITY GUARANTEE:** The garment/product is 10,000,000% identical to the reference image in every detail, while the surrounding flagship visual design, lighting, and infographics elevate it to an elite top-seller e-commerce listing.""" }, "flagship_styles": { "studio": "Shot on Phase One XF, 120mm lens. Impeccable, high-end commercial studio. Flawless, diffused lighting from a large octabox softbox. Set against a seamless, neutral cyclorama. Ultra-high resolution (8K), tack-sharp focus.", "product_focus_light": "E-commerce perfection for a luxury platform. Pure white or very light grey seamless background. Bright, even, shadowless lighting. Hyper-detailed, ultra-sharp.", "street": "Cinematic street style narrative. Vibrant metropolis. Captured with a Leica M11, 50mm f/1.4. Natural urban lighting with subtle motion blur.", "lookbook": "High-concept lookbook aesthetic. Textured, minimalist background. Soft, directional light creating a sophisticated mood with long shadows.", "minimalism": "Extreme architectural minimalism. Solitary figure against brutalist concrete. Single hard light source casting dramatic shadows.", "urban_loft_lifestyle": "Candid lifestyle narrative in a sun-drenched industrial loft. Exposed brick, large windows. Natural light and atmosphere.", "elevator_mirror_selfie": "Authentic mirror selfie in a luxurious elevator with brass or polished steel walls. Realistic reflection, soft ceiling lights.", "car_interior_lifestyle": "Candid shot inside a luxury car. Sunlight streaming through window, rich leather interior details.", "golden_hour_field": "Amateur-style portrait in a field of tall grass during golden hour. Strong warm backlight, beautiful lens flare.", "messy_bedroom_morning": "Candid 'I woke up like this' photo in a stylishly messy bedroom. Soft morning light, unmade linen sheets.", "vintage_35mm_film": "Emulates a 1990s 35mm film camera photo. Film grain, slightly muted colors, warm cast, soft focus.", "paparazzi_street_flash": "Dynamic night shot on a city street, paparazzi style. Harsh direct on-camera flash, stark shadows, saturated colors.", "cafe_window_contemplation": "Shot from outside a cozy cafe looking in. Reflections on window glass, quiet cinematic mood.", "rooftop_party_sunset": "Vibrant photo from a rooftop party at sunset. City skyline, warm setting sun colors.", "bookstore_aisle_cozy": "Warm shot in a bookstore with wooden shelves. Ambient light, intellectual cozy atmosphere.", "custom_background_fusion": "**MANDATORY: CUSTOM BACKGROUND FUSION PROTOCOL.** Seamlessly integrated into a separately provided background image. Perfect lighting and perspective matching.", "polaroid_snapshot": "Authentic vintage Polaroid SX-70 emulation. Soft focus, desaturated nostalgic palette, subtle vignette, white border.", "creative": "Avant-garde conceptual art piece. Unique sculptural props, theatrical surreal lighting.", "retro": "Authentic 1970s 35mm film emulation. Warm palette, film grain, light leaks, era-appropriate styling.", "boho": "Golden hour bohemian dreamscape. Field of wildflowers at sunset, magic hour halo effect.", "forest_nymph": "Ethereal photoshoot in a mystical forest. Cinematic crepuscular rays, serene magical atmosphere.", "desert_expedition": "High-fashion expedition in a vast desert landscape. Colossal sand dunes, direct bright lighting, sharp shadows.", "gothic": "Moody gothic romance in ancient architecture. Low-key chiaroscuro lighting, deep shadows.", "editorial": "High-fashion glossy magazine cover. Bold saturated background, clever use of mirrors.", "film_noir": "Cinematic black and white film noir. High contrast chiaroscuro lighting, long hard shadows.", "cottagecore": "Idyllic cottagecore aesthetic. Rustic garden setting, soft natural light.", "royalcore": "Opulent royalcore aesthetic. Lavish baroque palace interior, grand dramatic lighting.", "solarpunk": "Optimistic solarpunk future. Sleek architecture with vertical gardens, bright clean light.", "skater": "Dynamic skater aesthetic. Low-angle shot in a concrete skate park, raw youth energy.", "vibrant_market": "Bustling colorful street market. Shallow depth of field, vibrant textures.", "cyberpunk": "Gritty neon-drenched cyberpunk cityscape at night. Cool cinematic palette, wet streets.", "fantasy": "Enchanting epic fantasy world. Magical forest or ethereal landscape, mystical lighting.", "surreal_dreamscape": "Surreal impossible landscape. Non-realistic magical lighting, conceptual story.", "techwear": "Sleek functional Techwear style. Futuristic brutalist architecture, clean high-contrast lighting.", "home_casual": "Cozy authentic home setting. Soft natural light, warm inviting atmosphere.", "backstage": "Hectic backstage of a fashion show. Racks of couture, chaotic mixed lighting.", "road_trip": "Cinematic American West road trip. Vintage car against open landscape at sunset.", "rainy_day": "Romantic rainy day scene. Wet cobblestone reflections, diffused overcast light.", "night_flash": "Edgy direct-flash night photography. High contrast, raw spontaneous feel.", "tropical_resort": "Luxury tropical resort. Infinity pool or turquoise ocean, bright airy lighting.", "beach": "Sun-drenched pristine beach. Golden hour soft warm glow, long shadows.", "alaska_winter": "Epic Alaskan winter expedition. Snow-covered mountains, crisp clear blue sky, low winter sun.", "football_field": "Professional football pitch. Solitary iconic figure, dramatic stadium floodlights.", "volcanic_ash_desert": "Black sand volcanic beach in Iceland. Overcast moody light, high-contrast monochromatic palette.", "salt_flats_mirage": "Endless white salt flats of Salar de Uyuni. Harsh sun, mirage heat haze, mirror reflections.", "highlands_majesty": "Majestic Scottish Highlands. Ancient stones, atmospheric fog, muted natural palette.", "neon_tokyo": "Cinematic neon-lit Tokyo street at night. Bright colorful reflections, magenta and cyan lighting.", "paris_chic": "Elegant Parisian balcony with Eiffel Tower view. Soft romantic diffused sunlight.", "y2k_grunge": "Y2K early 2000s grunge. Flash photography, wide-angle distortion, retro arcade setting.", "old_money_mansion": "Old money aesthetic. Opulent mansion interior, dark wood, muted sophisticated lighting.", "high_end_runway": "Paris Fashion Week high-end runway. Bright spotlight, dark background, blurred audience.", "cyber_snow": "Cyberpunk winter dystopia. Model amidst heavy, glowing neon snow. High-tech winter gear, atmospheric blue and magenta lighting, cinematic wet reflections on ice. Ultra-detailed 8K.", "ethereal_ice_palace": "Ethereal haute couture in a crystalline ice palace. Glimmering frozen architecture, soft volumetric light passing through ice, ultra-luxurious, magical winter aesthetic. Extreme photorealism.", "neon_blizzard": "Nighttime neon blizzard. Harsh, dynamic flash lighting capturing frozen snowflakes mid-air. High contrast, saturated cyberpunk colors, extreme winter conditions. Shot on 35mm.", "high_fashion_tundra": "High-fashion editorial in a desolate, frozen tundra. Overcast, diffused arctic light. Stark, monochromatic white landscape emphasizing the garment's texture and silhouette. Shot on medium format Phase One.", "ski_resort_luxe": "Apres-ski luxury at an elite alpine resort. Sunlight reflecting off pristine snow, majestic mountain peaks in the background. Wealthy, effortless winter lifestyle aesthetic. Crisp, sharp focus.", "liquid_chrome_studio": "Avant-garde studio setting entirely made of liquid chrome and reflective liquid metal. Surreal reflections, high-end 3D-rendered look but hyper-photorealistic. Infinite silver backdrop.", "holographic_runway": "Futuristic runway show with holographic, glowing light structures. Intense, dramatic spotlighting. High energy, elite fashion week atmosphere. Motion blur on the background.", "vogue_mars": "Sci-fi fashion editorial set on the surface of Mars. Red dusty terrain, alien rock formations, two moons in the sky. Cinematic warm lighting, highly conceptual and majestic.", "quiet_luxury_hotel": "Real editorial photograph in a discreet five-star hotel lobby with limestone, walnut, wool upholstery and soft window light. Subtle signs of use, coherent architecture and restrained quiet-luxury palette.", "mediterranean_old_town": "Real fashion photograph in a Mediterranean old-town lane with aged lime plaster, worn stone paving, wooden shutters and warm late-afternoon sunlight.", "rainy_greenhouse": "Photographed inside a real botanical greenhouse during rain, with condensation, irregular droplets, damp stone and soft overcast daylight.", "modern_museum_steps": "Contemporary fashion editorial on the exterior steps of a real modern art museum, with honest concrete texture and clean structural geometry.", "coastal_wind": "Real location fashion shoot on a rugged coast in steady wind, with natural fabric movement, salt haze and subdued ocean color.", "art_gallery_opening": "Candid editorial photograph at a believable contemporary gallery opening with mixed track lighting and window ambience." }, "object_styles": { "studio": "Ultimate commercial studio product shot. Neutral background, perfect diffused lighting.", "minimalism": "Minimalist artistic composition. Textured surface, single crisp hard light source.", "nature": "Artfully placed in a pristine natural environment. Natural lighting, organic feel.", "luxe": "Luxury still life. Rich tactile surface like silk or marble, low-key sophisticated lighting.", "dark": "Moody dark academia aesthetic. Dark textured background, single directional light beam.", "geometric": "Strong geometric shapes and color blocks. Modern, bold, visually arresting.", "floating": "Levitating weightlessly against a clean minimalist background. Soft drop shadow.", "lifestyle": "Realistic aspirational everyday context. Helps visualize product in elite lives.", "splash": "Dynamic high-speed splash of liquid frozen in time. Freshness and energy.", "handmade": "Rustic artisanal workshop setting. Emphasizes craftsmanship and authenticity.", "alaska_winter": "Pristine Alaskan winter. Frost-covered rock amidst fresh snow, crisp bright light.", "football_field": "Vibrant green turf of a professional football field. Stadium floodlights.", "volcanic_ash_desert": "Dramatic jet-black volcanic rock on a black sand beach. Moody diffused light.", "salt_flats_mirage": "Stark white crystalline surface of a salt flat. Intense sun, sharp shadow.", "highlands_majesty": "Ancient mossy stone in Scottish Highlands. Soft natural light, realistic water droplets.", "liquid_metal_pedestal": "Shifting pedestal of liquid silver or chrome. Highly reflective, futuristic.", "neon_cyber_platform": "Glowing neon grid or cybernetic platform. Vibrant laser-like edge lighting.", "botanical_greenhouse": "Nested within exotic lush green foliage inside a sunlit botanical greenhouse.", "frozen_ice_block": "Beautifully frozen inside a crystal-clear block of carved ice. Crisp cold lighting.", "crystal_podium": "Elevated on a pristine multi-faceted crystal podium. Rainbow light refraction.", "velvet_pedestal": "Rests on a deep plush velvet pedestal. Extremely soft rich textures.", "travertine_gallery": "Premium product photograph on real honed travertine in a quiet architectural gallery with soft side window light.", "artisan_kitchen": "Believable product photograph in a working artisan kitchen with real timber, linen and glazed ceramic surfaces.", "sunlit_bathroom": "Clean lifestyle product photograph in a real sunlit bathroom with handmade tile, lime plaster and diffused window light.", "archival_table": "Museum-like product study on an archival worktable with neutral paper, cotton gloves and precise soft task lighting." } } with open(PROMPTS_FILE, 'w', encoding='utf-8') as f: json.dump(prompts_data, f, ensure_ascii=False, indent=4) def load_prompts(): if not os.path.exists(PROMPTS_FILE): setup_initial_files() try: with open(PROMPTS_FILE, 'r', encoding='utf-8') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): if not HF_TOKEN_READ and not HF_TOKEN_WRITE: return False token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE files_to_download = [specific_file] if specific_file else SYNC_FILES all_successful = True for file_name in files_to_download: success = False for attempt in range(retries + 1): try: hf_hub_download( repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use, local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False ) success = True break except RepositoryNotFoundError: all_successful = False break except HfHubHTTPError as e: if e.response.status_code == 404: if attempt == 0 and not os.path.exists(file_name): try: if file_name == DATA_FILE: with open(file_name, 'w', encoding='utf-8') as f: json.dump({}, f) elif file_name == PROMPTS_FILE: setup_initial_files() except Exception: pass success = True break else: pass except Exception: pass if attempt < retries: time.sleep(delay) if not success: all_successful = False return all_successful def upload_db_to_hf(specific_file=None): if not HF_TOKEN_WRITE: return try: api = HfApi() files_to_upload = [specific_file] if specific_file else SYNC_FILES for file_name in files_to_upload: if os.path.exists(file_name): try: api.upload_file( path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) except Exception: pass except Exception: pass def periodic_backup(): backup_interval = 1800 while True: time.sleep(backup_interval) with data_lock: upload_db_to_hf() def load_data(): data = {} if os.path.exists(DATA_FILE): try: with open(DATA_FILE, 'r', encoding='utf-8') as f: data = json.load(f) except json.JSONDecodeError: if download_db_from_hf(specific_file=DATA_FILE): try: with open(DATA_FILE, 'r', encoding='utf-8') as f: data = json.load(f) except (FileNotFoundError, json.JSONDecodeError): data = {} elif download_db_from_hf(specific_file=DATA_FILE): try: with open(DATA_FILE, 'r', encoding='utf-8') as f: data = json.load(f) except (FileNotFoundError, json.JSONDecodeError): data = {} if not isinstance(data, dict): data = {} return data def save_data(data): try: with open(DATA_FILE_TEMP, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) os.replace(DATA_FILE_TEMP, DATA_FILE) upload_db_to_hf(specific_file=DATA_FILE) except Exception: if os.path.exists(DATA_FILE_TEMP): os.remove(DATA_FILE_TEMP) LANDING_PAGE_TEMPLATE = ''' MetaStore - AI система для Вашего Бизнеса Поддержка ''' ADMHOSTO_TEMPLATE = ''' Админ-панель

Управление Средами

Техподдержка {% if support_unread_total %}{{ support_unread_total }}{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %}

Активные Среды

{% if active_environments %} {% else %}
Список активных сред пуст
{% endif %}

Архив

{% if archived_environments %} {% else %}
Архив пуст
{% endif %}
''' SYNKRIS_LOOK_TEMPLATE = ''' Synkris
{{ support_default_label }}{% if support_unread %}{{ support_unread }}{% endif %}

Synkris

PROMPT GENERATOR & LAUNCHER

1 Model parameters

2 Scene & Style

Advanced photo settings

3 Composition & Details

1 Model Parameters

2 Scene & Style

Advanced photo settings

3 Composition & Details

1 Object Parameters

2 Scene & Style

3 Composition & Details

1 Product & Model

2 Scene & Style

3 Composition & Details

1 Jewelry, model & placement

2 Scene & Style

Jewelry photography settings

3 Composition & Details

''' @app.route('/') def index(): return render_template_string(LANDING_PAGE_TEMPLATE, support_url=url_for('general_support', lang='ru')) @app.route('/admhosto', methods=['GET']) def admhosto(): data = load_data() active_environments = [] archived_environments = [] for env_id, env_data in data.items(): if env_id == GLOBAL_SUPPORT_KEY or not isinstance(env_data, dict): continue env_item = { "id": env_id, "keyword": env_data.get("keyword", "N/A"), "type": env_data.get("type", "closed"), "hits": env_data.get("hits", 0), "created_at": env_data.get("created_at", ""), "support_unread": _unread_in_bucket(env_data, 'admin'), "support_blocked": bool(env_data.get("support_blocked", False)), "link": url_for('serve_env', env_id=env_id, _external=True) } if env_data.get("archived"): archived_environments.append(env_item) else: active_environments.append(env_item) active_environments.sort(key=lambda x: x.get('created_at', ''), reverse=True) archived_environments.sort(key=lambda x: x.get('created_at', ''),reverse=True) return render_template_string(ADMHOSTO_TEMPLATE, active_environments=active_environments, archived_environments=archived_environments, support_unread_total=_total_support_unread(data, 'admin')) @app.route('/admhosto/create', methods=['POST']) def create_environment(): keyword = request.form.get('keyword', '').strip() env_type = request.form.get('env_type', 'closed') if not keyword: flash('Ключевое слово не может быть пустым.', 'error') return redirect(url_for('admhosto')) with data_lock: all_data = load_data() while True: new_id = ''.join(random.choices(string.digits, k=6)) if new_id not in all_data: break all_data[new_id] = { "keyword": keyword, "type": env_type, "device_token": None, "hits": 0, "logs": [], "created_at": datetime.utcnow().isoformat(), "archived": False, "support_blocked": False, "support_tickets": [] } save_data(all_data) flash(f'Новая {env_type} среда с ID {new_id} создана.', 'success') return redirect(url_for('admhosto')) @app.route('/admhosto/delete/', methods=['POST']) def delete_environment(env_id): with data_lock: all_data = load_data() if env_id in all_data: all_data[env_id]['archived'] = True save_data(all_data) flash(f'Среда {env_id} перемещена в архив.', 'success') else: flash(f'Среда {env_id} не найдена.', 'error') return redirect(url_for('admhosto')) @app.route('/admhosto/restore/', methods=['POST']) def restore_environment(env_id): with data_lock: all_data = load_data() if env_id in all_data: all_data[env_id]['archived'] = False save_data(all_data) flash(f'Среда {env_id} восстановлена из архива.', 'success') else: flash(f'Среда {env_id} не найдена.', 'error') return redirect(url_for('admhosto')) @app.route('/admhosto/clear_user/', methods=['POST']) def clear_user(env_id): with data_lock: all_data = load_data() if env_id in all_data and all_data[env_id].get('type') == 'closed': all_data[env_id]['device_token'] = None save_data(all_data) flash(f'Пользователь отвязан от среды {env_id}.', 'success') else: flash(f'Ошибка: Среда не найдена или не является закрытой.', 'error') return redirect(url_for('admhosto')) @app.route('/admhosto/toggle_type/', methods=['POST']) def toggle_type(env_id): with data_lock: all_data = load_data() if env_id in all_data: current_type = all_data[env_id].get('type', 'closed') if current_type == 'closed': all_data[env_id]['type'] = 'open' flash(f'Среда {env_id} теперь открыта.', 'success') else: all_data[env_id]['type'] = 'closed' all_data[env_id]['device_token'] = None flash(f'Среда {env_id} теперь закрыта. Пользователь сброшен.', 'success') save_data(all_data) else: flash(f'Среда {env_id} не найдена.', 'error') return redirect(url_for('admhosto')) @app.route('/admhosto/open_all', methods=['POST']) def open_all_environments(): with data_lock: all_data = load_data() count = 0 for env_id, env_data in all_data.items(): if not env_data.get('archived') and env_data.get('type') == 'closed': env_data['type'] = 'open' count += 1 if count > 0: save_data(all_data) flash(f'{count} сред(ы) были успешно открыты.', 'success') else: flash('Нет закрытых активных сред для открытия.', 'info') return redirect(url_for('admhosto')) @app.route('/admhosto/stats/') def get_env_stats(env_id): with data_lock: data = load_data() env_data = data.get(env_id) if not env_data: return jsonify({"error": "Среда ненайдена"}), 404 raw_logs = env_data.get("logs", []) formatted_logs = [] for log in reversed(raw_logs): try: utc_dt = datetime.fromisoformat(log['time']) almaty_dt = utc_dt + timedelta(hours=5) time_str = almaty_dt.strftime('%Y-%m-%d %H:%M:%S') formatted_logs.append({ "time": time_str, "ip": log.get('ip', 'unknown'), "ua": log.get('ua', 'unknown') }) except: continue response_data = { "id": env_id, "keyword": env_data.get("keyword"), "type": env_data.get("type", "closed"), "hits": env_data.get("hits", 0), "logs": formatted_logs } return jsonify(response_data) @app.route('/env/') def serve_env(env_id): new_token_to_set = None with data_lock: data = load_data() env_data = data.get(env_id) if not env_data or not isinstance(env_data, dict) or env_data.get("archived"): return "Среда не найдена или заархивирована.", 404 current_log = { "time": datetime.utcnow().isoformat(), "ip": request.headers.get('X-Forwarded-For', request.remote_addr), "ua": request.headers.get('User-Agent', '')[:150] } env_data['hits'] = env_data.get('hits', 0) + 1 if 'logs' not in env_data or not isinstance(env_data.get('logs'), list): env_data['logs'] = [] env_data['logs'].append(current_log) if len(env_data['logs']) > 30: env_data['logs'] = env_data['logs'][-30:] env_type = env_data.get("type", "closed") support_unread = _unread_in_bucket(env_data, 'user', _support_actor_id()) if env_type == 'closed': stored_token = env_data.get("device_token") if not stored_token: new_token_to_set = ''.join(random.choices(string.ascii_letters + string.digits, k=40)) env_data['device_token'] = new_token_to_set data[env_id] = env_data save_data(data) keyword = env_data.get("keyword", "") prompts_data = load_prompts() support_context = _support_link_context(env_id, support_unread) if env_type == 'open': return render_template_string(SYNKRIS_LOOK_TEMPLATE, keyword=keyword, prompts_data=prompts_data, **support_context) user_token = request.cookies.get(f'access_token_{env_id}') stored_token = env_data.get("device_token") if new_token_to_set: resp = make_response(render_template_string(SYNKRIS_LOOK_TEMPLATE, keyword=keyword, prompts_data=prompts_data, **support_context)) resp.set_cookie(f'access_token_{env_id}', new_token_to_set, max_age=31536000, httponly=True, samesite='Lax') return resp elif stored_token and user_token == stored_token: return render_template_string(SYNKRIS_LOOK_TEMPLATE, keyword=keyword, prompts_data=prompts_data, **support_context) else: denied_page = """ Доступ запрещен __SUPPORT_LABEL__

⛔ Доступ запрещен

Эта ссылка уже привязана к другому устройству или браузеру.

""" denied_lang = session.get('support_lang', 'en') denied_label = _support_texts(denied_lang).get('support_button', 'Support') return denied_page.replace('__SUPPORT_URL__', url_for('support_portal', env_id=env_id)).replace('__SUPPORT_LABEL__', denied_label), 403 # --- In-app technical support ------------------------------------------------- # Support requests live in data.json alongside each environment. Attachments are # stored locally and mirrored to the existing Hugging Face dataset when possible. import mimetypes import re import uuid from copy import deepcopy GLOBAL_SUPPORT_KEY = '_global_support' SUPPORT_MEDIA_DIR = os.getenv('SUPPORT_MEDIA_DIR', 'support_media') SUPPORT_IMAGE_LIMIT = 8 * 1024 * 1024 SUPPORT_IMAGE_TYPES = { '.jpg': ('image/jpeg', b'jpeg'), '.jpeg': ('image/jpeg', b'jpeg'), '.png': ('image/png', b'png'), '.gif': ('image/gif', b'gif'), '.webp': ('image/webp', b'webp'), } SUPPORT_LANGUAGES = ('ru', 'uk', 'tr', 'pl', 'en', 'zh', 'vi') SUPPORT_LANGUAGE_NAMES = { 'ru': 'Русский', 'uk': 'Українська', 'tr': 'Türkçe', 'pl': 'Polski', 'en': 'English', 'zh': '中文', 'vi': 'Tiếng Việt' } app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 SUPPORT_UI = { 'ru': { 'support_button':'Поддержка','support_title':'Техническая поддержка','support_subtitle':'Напишите администратору — ответ появится в этой переписке.','back_to_app':'Вернуться в генератор', 'new_request':'Новое обращение','your_requests':'Ваши обращения','no_requests':'Вы ещё не отправляли обращений.','choose_request':'Выберите обращение, чтобы открыть переписку.', 'subject':'Тема','subject_placeholder':'Кратко опишите проблему','message':'Сообщение','message_placeholder':'Опишите проблему и укажите, что уже пробовали сделать…','reply_placeholder':'Напишите ответ…', 'attach_image':'Прикрепить изображение','upload_note':'JPG, PNG, WebP или GIF; до 8 МБ.','send':'Отправить','reply':'Ответить','open':'Открыто','closed':'Закрыто', 'support_disabled':'Поддержка для этой ссылки временно отключена. Переписку можно просматривать, но отправлять сообщения нельзя.','admin':'Администратор','you':'Вы','attached_image':'Изображение приложено','created':'Создано', 'send_failed':'Не удалось сохранить обращение. Попробуйте ещё раз.','message_required':'Введите сообщение или прикрепите изображение.','ticket_not_found':'Обращение не найдено.','language':'Язык','unread':'непрочитано', 'close_request':'Закрыть обращение','reopen_request':'Открыть обращение','disable_support':'Отключить поддержку','enable_support':'Включить поддержку','requests_title':'Обращения в поддержку','no_messages':'Сообщений пока нет.', 'invalid_image':'Допустимы только изображения JPG, PNG, WebP и GIF.','image_too_large':'Размер изображения не должен превышать 8 МБ.','ticket_saved':'Обращение отправлено. Ответ появится здесь.','reply_saved':'Ответ отправлен.' }, 'uk': { 'support_button':'Підтримка','support_title':'Технічна підтримка','support_subtitle':'Напишіть адміністратору — відповідь з’явиться в цьому листуванні.','back_to_app':'Повернутися до генератора', 'new_request':'Нове звернення','your_requests':'Ваші звернення','no_requests':'Ви ще не надсилали звернень.','choose_request':'Виберіть звернення, щоб відкрити листування.', 'subject':'Тема','subject_placeholder':'Коротко опишіть проблему','message':'Повідомлення','message_placeholder':'Опишіть проблему та вкажіть, що вже пробували зробити…','reply_placeholder':'Напишіть відповідь…', 'attach_image':'Додати зображення','upload_note':'JPG, PNG, WebP або GIF; до 8 МБ.','send':'Надіслати','reply':'Відповісти','open':'Відкрито','closed':'Закрито', 'support_disabled':'Підтримку для цього посилання тимчасово вимкнено. Переглядати листування можна, але надсилати повідомлення — ні.','admin':'Адміністратор','you':'Ви','attached_image':'Зображення додано','created':'Створено', 'send_failed':'Не вдалося зберегти звернення. Спробуйте ще раз.','message_required':'Введіть повідомлення або додайте зображення.','ticket_not_found':'Звернення не знайдено.','language':'Мова','unread':'непрочитано', 'close_request':'Закрити звернення','reopen_request':'Відкрити звернення','disable_support':'Вимкнути підтримку','enable_support':'Увімкнути підтримку','requests_title':'Звернення до підтримки','no_messages':'Повідомлень поки немає.', 'invalid_image':'Дозволено лише зображення JPG, PNG, WebP і GIF.','image_too_large':'Розмір зображення не має перевищувати 8 МБ.','ticket_saved':'Звернення надіслано. Відповідь з’явиться тут.','reply_saved':'Відповідь надіслано.' }, 'tr': { 'support_button':'Destek','support_title':'Teknik destek','support_subtitle':'Yöneticiye yazın; yanıt bu konuşmada görünecek.','back_to_app':'Oluşturucuya dön', 'new_request':'Yeni talep','your_requests':'Talepleriniz','no_requests':'Henüz bir talep göndermediniz.','choose_request':'Konuşmayı görüntülemek için bir talep seçin.', 'subject':'Konu','subject_placeholder':'Sorunu kısaca açıklayın','message':'Mesaj','message_placeholder':'Sorunu ve denediğiniz adımları açıklayın…','reply_placeholder':'Yanıtınızı yazın…', 'attach_image':'Görsel ekle','upload_note':'JPG, PNG, WebP veya GIF; en fazla 8 MB.','send':'Gönder','reply':'Yanıtla','open':'Açık','closed':'Kapalı', 'support_disabled':'Bu bağlantı için destek geçici olarak devre dışı. Konuşmayı görüntüleyebilirsiniz ancak mesaj gönderemezsiniz.','admin':'Yönetici','you':'Siz','attached_image':'Görsel eklendi','created':'Oluşturulma', 'send_failed':'Talep kaydedilemedi. Lütfen tekrar deneyin.','message_required':'Bir mesaj yazın veya görsel ekleyin.','ticket_not_found':'Talep bulunamadı.','language':'Dil','unread':'okunmamış', 'close_request':'Talebi kapat','reopen_request':'Talebi yeniden aç','disable_support':'Desteği devre dışı bırak','enable_support':'Desteği etkinleştir','requests_title':'Destek talepleri','no_messages':'Henüz mesaj yok.', 'invalid_image':'Yalnızca JPG, PNG, WebP ve GIF görselleri kabul edilir.','image_too_large':'Görsel boyutu 8 MB’ı aşmamalıdır.','ticket_saved':'Talebiniz gönderildi. Yanıt burada görünecek.','reply_saved':'Yanıt gönderildi.' }, 'pl': { 'support_button':'Pomoc','support_title':'Pomoc techniczna','support_subtitle':'Napisz do administratora — odpowiedź pojawi się w tej rozmowie.','back_to_app':'Wróć do generatora', 'new_request':'Nowe zgłoszenie','your_requests':'Twoje zgłoszenia','no_requests':'Nie wysłano jeszcze żadnych zgłoszeń.','choose_request':'Wybierz zgłoszenie, aby otworzyć rozmowę.', 'subject':'Temat','subject_placeholder':'Krótko opisz problem','message':'Wiadomość','message_placeholder':'Opisz problem i napisz, co zostało już wypróbowane…','reply_placeholder':'Napisz odpowiedź…', 'attach_image':'Dołącz obraz','upload_note':'JPG, PNG, WebP lub GIF; maks. 8 MB.','send':'Wyślij','reply':'Odpowiedz','open':'Otwarte','closed':'Zamknięte', 'support_disabled':'Pomoc dla tego linku jest tymczasowo wyłączona. Możesz przeglądać rozmowę, ale nie wysyłać wiadomości.','admin':'Administrator','you':'Ty','attached_image':'Dołączono obraz','created':'Utworzono', 'send_failed':'Nie udało się zapisać zgłoszenia. Spróbuj ponownie.','message_required':'Wpisz wiadomość lub dołącz obraz.','ticket_not_found':'Nie znaleziono zgłoszenia.','language':'Język','unread':'nieprzeczytane', 'close_request':'Zamknij zgłoszenie','reopen_request':'Otwórz ponownie','disable_support':'Wyłącz pomoc','enable_support':'Włącz pomoc','requests_title':'Zgłoszenia do pomocy','no_messages':'Brak wiadomości.', 'invalid_image':'Dozwolone są tylko obrazy JPG, PNG, WebP i GIF.','image_too_large':'Obraz nie może być większy niż 8 MB.','ticket_saved':'Zgłoszenie wysłano. Odpowiedź pojawi się tutaj.','reply_saved':'Odpowiedź wysłana.' }, 'en': { 'support_button':'Support','support_title':'Technical support','support_subtitle':'Message the administrator; replies will appear in this conversation.','back_to_app':'Back to generator', 'new_request':'New request','your_requests':'Your requests','no_requests':'You have not sent any requests yet.','choose_request':'Select a request to open the conversation.', 'subject':'Subject','subject_placeholder':'Briefly describe the issue','message':'Message','message_placeholder':'Describe the issue and tell us what you have tried…','reply_placeholder':'Write a reply…', 'attach_image':'Attach an image','upload_note':'JPG, PNG, WebP or GIF; up to 8 MB.','send':'Send','reply':'Reply','open':'Open','closed':'Closed', 'support_disabled':'Support for this link is temporarily disabled. You can view the conversation, but cannot send messages.','admin':'Administrator','you':'You','attached_image':'Image attached','created':'Created', 'send_failed':'The request could not be saved. Please try again.','message_required':'Enter a message or attach an image.','ticket_not_found':'Request not found.','language':'Language','unread':'unread', 'close_request':'Close request','reopen_request':'Reopen request','disable_support':'Disable support','enable_support':'Enable support','requests_title':'Support requests','no_messages':'No messages yet.', 'invalid_image':'Only JPG, PNG, WebP and GIF images are allowed.','image_too_large':'The image must be 8 MB or smaller.','ticket_saved':'Your request was sent. Replies will appear here.','reply_saved':'Reply sent.' }, 'zh': { 'support_button':'技术支持','support_title':'技术支持','support_subtitle':'给管理员留言,回复会显示在此对话中。','back_to_app':'返回生成器', 'new_request':'新建请求','your_requests':'我的请求','no_requests':'您还没有提交请求。','choose_request':'请选择一个请求以查看对话。', 'subject':'主题','subject_placeholder':'简要描述问题','message':'消息','message_placeholder':'请描述问题以及您已尝试的操作…','reply_placeholder':'写下回复…', 'attach_image':'附加图片','upload_note':'JPG、PNG、WebP 或 GIF;最大 8 MB。','send':'发送','reply':'回复','open':'处理中','closed':'已关闭', 'support_disabled':'此链接的支持功能暂时已关闭。您可以查看对话,但无法发送消息。','admin':'管理员','you':'您','attached_image':'已附加图片','created':'创建时间', 'send_failed':'无法保存请求,请重试。','message_required':'请输入消息或附加图片。','ticket_not_found':'未找到该请求。','language':'语言','unread':'条未读', 'close_request':'关闭请求','reopen_request':'重新打开请求','disable_support':'关闭支持','enable_support':'启用支持','requests_title':'支持请求','no_messages':'暂无消息。', 'invalid_image':'仅支持 JPG、PNG、WebP 和 GIF 图片。','image_too_large':'图片大小不能超过 8 MB。','ticket_saved':'请求已发送,回复会显示在这里。','reply_saved':'回复已发送。' }, 'vi': { 'support_button':'Hỗ trợ','support_title':'Hỗ trợ kỹ thuật','support_subtitle':'Gửi tin nhắn cho quản trị viên; phản hồi sẽ xuất hiện trong cuộc trò chuyện này.','back_to_app':'Quay lại trình tạo', 'new_request':'Yêu cầu mới','your_requests':'Yêu cầu của bạn','no_requests':'Bạn chưa gửi yêu cầu nào.','choose_request':'Chọn một yêu cầu để mở cuộc trò chuyện.', 'subject':'Chủ đề','subject_placeholder':'Mô tả ngắn gọn vấn đề','message':'Tin nhắn','message_placeholder':'Mô tả vấn đề và những cách bạn đã thử…','reply_placeholder':'Viết phản hồi…', 'attach_image':'Đính kèm hình ảnh','upload_note':'JPG, PNG, WebP hoặc GIF; tối đa 8 MB.','send':'Gửi','reply':'Trả lời','open':'Đang mở','closed':'Đã đóng', 'support_disabled':'Hỗ trợ cho liên kết này đang tạm tắt. Bạn có thể xem cuộc trò chuyện nhưng không thể gửi tin nhắn.','admin':'Quản trị viên','you':'Bạn','attached_image':'Đã đính kèm hình ảnh','created':'Đã tạo', 'send_failed':'Không thể lưu yêu cầu. Vui lòng thử lại.','message_required':'Hãy nhập tin nhắn hoặc đính kèm hình ảnh.','ticket_not_found':'Không tìm thấy yêu cầu.','language':'Ngôn ngữ','unread':'chưa đọc', 'close_request':'Đóng yêu cầu','reopen_request':'Mở lại yêu cầu','disable_support':'Tắt hỗ trợ','enable_support':'Bật hỗ trợ','requests_title':'Yêu cầu hỗ trợ','no_messages':'Chưa có tin nhắn.', 'invalid_image':'Chỉ chấp nhận ảnh JPG, PNG, WebP và GIF.','image_too_large':'Ảnh không được vượt quá 8 MB.','ticket_saved':'Yêu cầu đã được gửi. Phản hồi sẽ xuất hiện tại đây.','reply_saved':'Đã gửi phản hồi.' } } @app.errorhandler(413) def support_request_too_large(error): lang = session.get('support_lang', 'en') if lang not in SUPPORT_LANGUAGES: lang = 'en' return _support_texts(lang)['image_too_large'], 413 SUPPORT_PORTAL_TEMPLATE = ''' {{ t.support_title }} · {{ env_name }}
← {{ t.back_to_app }}
{{ env_name }}

{{ t.support_title }}

{{ t.support_subtitle }}

{% if selected_ticket %}{% endif %}
{% with messages=get_flashed_messages(with_categories=true) %}{% if messages %}{% for category,message in messages %}
{{ message }}
{% endfor %}{% endif %}{% endwith %}

{{ t.your_requests }}

{{ tickets|length }}
{% if not support_blocked %}
{{ t.upload_note }}
{% endif %}
{% if selected_ticket %}

{{ selected_ticket.subject }}

{{ t.created }} · {{ selected_ticket.created_display }}
{{ t.closed if selected_ticket.status == 'closed' else t.open }}
{% if selected_ticket.messages %}{% for msg in selected_ticket.messages %}
{{ t.you if msg.sender == 'user' else t.admin }}
{% if msg.text %}
{{ msg.text }}
{% endif %}{% if msg.image_url %}{{ t.attached_image }}{% endif %}
{% endfor %}{% else %}
{{ t.no_messages }}
{% endif %}
{% if support_blocked %}
{{ t.support_disabled }}
{% elif selected_ticket.status == 'closed' %}
{{ t.closed }}. {{ t.choose_request }}
{% else %}
{{ t.upload_note }}
{% endif %} {% else %}
{{ t.choose_request }}
{% endif %}
''' ADMHOSTO_SUPPORT_LIST_TEMPLATE = ''' {{ t.requests_title }}

◉ {{ t.requests_title }} {{ unread_total }}

← {{ back_label }}
{{ tickets|length }} · {{ t.requests_title|lower }}
{% if tickets %}{% else %}
{{ t.no_requests }}
{% endif %}
''' ADMHOSTO_SUPPORT_THREAD_TEMPLATE = ''' {{ ticket.subject }} · {{ t.requests_title }}
← {{ t.requests_title }}
{% with messages=get_flashed_messages(with_categories=true) %}{% if messages %}{% for category,message in messages %}
{{ message }}
{% endfor %}{% endif %}{% endwith %}

{{ ticket.subject }}

{{ env_label }} · {{ t.created }}: {{ ticket.created_display }} · {{ t.closed if ticket.status == 'closed' else t.open }}
{% if support_blocked %}
{{ t.support_disabled }}
{% endif %}
{% for msg in ticket.messages %}
{{ t.you if msg.sender == 'user' else t.admin }}
{% if msg.text %}
{{ msg.text }}
{% endif %}{% if msg.image_url %}{{ t.attached_image }}{% endif %}
{% else %}

{{ t.no_messages }}

{% endfor %}
{{ t.upload_note }}
''' def _support_lang(): candidate = request.args.get('lang') or request.form.get('lang') or session.get('support_lang') or 'en' if candidate not in SUPPORT_LANGUAGES: candidate = 'en' session['support_lang'] = candidate return candidate def _support_texts(lang): return SUPPORT_UI.get(lang, SUPPORT_UI['en']) def _support_actor_id(): actor_id = session.get('support_actor_id') if not actor_id: actor_id = uuid.uuid4().hex session['support_actor_id'] = actor_id return actor_id def _valid_support_env_id(env_id): return env_id == 'general' or bool(re.fullmatch(r'\d{6}', str(env_id or ''))) def _ensure_support_bucket(data, env_id): if env_id == 'general': bucket = data.get(GLOBAL_SUPPORT_KEY) if not isinstance(bucket, dict): bucket = {'keyword': 'Общая поддержка', 'support_tickets': [], 'support_blocked': False} data[GLOBAL_SUPPORT_KEY] = bucket else: bucket = data.get(env_id) if not isinstance(bucket, dict): return None tickets = bucket.get('support_tickets') if not isinstance(tickets, list): bucket['support_tickets'] = [] bucket.setdefault('support_blocked', False) return bucket def _find_support_ticket(bucket, ticket_id): if not bucket: return None for ticket in bucket.get('support_tickets', []): if isinstance(ticket, dict) and ticket.get('id') == ticket_id: return ticket return None def _unread_in_bucket(bucket, audience, actor_id=None): count = 0 if not isinstance(bucket, dict): return 0 for ticket in bucket.get('support_tickets', []): if not isinstance(ticket, dict): continue if actor_id is not None and ticket.get('actor_id') != actor_id: continue for message in ticket.get('messages', []): if not isinstance(message, dict): continue if audience == 'admin' and message.get('sender') == 'user' and not message.get('read_by_admin', False): count += 1 elif audience == 'user' and message.get('sender') == 'admin' and not message.get('read_by_user', False): count += 1 return count def _total_support_unread(data, audience='admin', actor_id=None): count = 0 for key, bucket in data.items(): if key == GLOBAL_SUPPORT_KEY or (isinstance(key, str) and re.fullmatch(r'\d{6}', key)): count += _unread_in_bucket(bucket, audience, actor_id) return count def _support_time(value): try: moment = datetime.fromisoformat(value) return moment.strftime('%Y-%m-%d %H:%M') except (TypeError, ValueError): return str(value or '') def _support_image_type(payload): if payload.startswith(b'\xff\xd8\xff'): return '.jpg', 'image/jpeg' if payload.startswith(b'\x89PNG\r\n\x1a\n'): return '.png', 'image/png' if payload.startswith((b'GIF87a', b'GIF89a')): return '.gif', 'image/gif' if len(payload) >= 12 and payload[:4] == b'RIFF' and payload[8:12] == b'WEBP': return '.webp', 'image/webp' return None, None class SupportUploadError(Exception): def __init__(self, key): self.key = key super().__init__(key) def _save_support_image(file_storage, env_id, ticket_id): if not file_storage or not file_storage.filename: return None payload = file_storage.stream.read(SUPPORT_IMAGE_LIMIT + 1) if len(payload) > SUPPORT_IMAGE_LIMIT: raise SupportUploadError('image_too_large') extension, content_type = _support_image_type(payload) if not extension: raise SupportUploadError('invalid_image') filename = uuid.uuid4().hex + extension safe_env = 'general' if env_id == 'general' else str(env_id) relative_directory = os.path.join(SUPPORT_MEDIA_DIR, safe_env, ticket_id) absolute_directory = os.path.abspath(relative_directory) os.makedirs(absolute_directory, exist_ok=True) absolute_path = os.path.join(absolute_directory, filename) with open(absolute_path, 'wb') as output: output.write(payload) if HF_TOKEN_WRITE: try: repo_path = '/'.join(('support_media', safe_env, ticket_id, filename)) HfApi().upload_file( path_or_fileobj=io.BytesIO(payload), path_in_repo=repo_path, repo_id=REPO_ID, repo_type='dataset', token=HF_TOKEN_WRITE, commit_message=f'Add support attachment {ticket_id}' ) except Exception as exc: logging.warning('Could not mirror support attachment to Hugging Face: %s', exc) return filename def _ticket_for_actor(bucket, ticket_id, actor_id): ticket = _find_support_ticket(bucket, ticket_id) if ticket and ticket.get('actor_id') == actor_id: return ticket return None def _user_support_url(env_id, **kwargs): if env_id == 'general': return url_for('general_support', **kwargs) return url_for('support_portal', env_id=env_id, **kwargs) def _support_link_context(env_id, unread_count): lang = session.get('support_lang', 'en') if lang not in SUPPORT_LANGUAGES: lang = 'en' default_texts = _support_texts(lang) link_texts = { code: {'button': _support_texts(code)['support_button'], 'title': _support_texts(code)['support_title']} for code in SUPPORT_LANGUAGES } return { 'support_url': _user_support_url(env_id), 'support_lang': lang, 'support_unread': int(unread_count or 0), 'support_default_label': default_texts['support_button'], 'support_default_title': default_texts['support_title'], 'support_link_texts': link_texts, } def _render_support_portal(env_id): if not _valid_support_env_id(env_id): return 'Support page not found.', 404 lang = _support_lang() actor_id = _support_actor_id() t = _support_texts(lang) with data_lock: data = load_data() if env_id != 'general' and not isinstance(data.get(env_id), dict): return 'Environment not found.', 404 bucket = data.get(GLOBAL_SUPPORT_KEY) if env_id == 'general' else data.get(env_id) blocked = bool(bucket.get('support_blocked', False)) if isinstance(bucket, dict) else False stored_tickets = bucket.get('support_tickets', []) if isinstance(bucket, dict) else [] tickets = [deepcopy(ticket) for ticket in stored_tickets if isinstance(ticket, dict) and ticket.get('actor_id') == actor_id] selected_id = request.args.get('ticket') or request.form.get('ticket_id') selected = next((ticket for ticket in tickets if ticket.get('id') == selected_id), None) if selected: changed = False for message in selected.get('messages', []): if message.get('sender') == 'admin' and not message.get('read_by_user', False): message['read_by_user'] = True changed = True if changed and isinstance(bucket, dict): original_ticket = _ticket_for_actor(bucket, selected_id, actor_id) if original_ticket: for message in original_ticket.get('messages', []): if message.get('sender') == 'admin': message['read_by_user'] = True save_data(data) elif tickets: selected = tickets[0] if env_id == 'general': env_name = 'Synkris' back_url = url_for('index') else: env_data = data.get(env_id) or {} env_name = env_data.get('keyword') or env_id back_url = url_for('serve_env', env_id=env_id) tickets.sort(key=lambda ticket: ticket.get('updated_at', ticket.get('created_at', '')), reverse=True) if selected: selected = next((ticket for ticket in tickets if ticket.get('id') == selected.get('id')), selected) for ticket in tickets: ticket['created_display'] = _support_time(ticket.get('created_at')) ticket['updated_display'] = _support_time(ticket.get('updated_at', ticket.get('created_at'))) ticket['unread_count'] = sum(1 for message in ticket.get('messages', []) if message.get('sender') == 'admin' and not message.get('read_by_user', False)) ticket['href'] = _user_support_url(env_id, ticket=ticket.get('id'), lang=lang) for message in ticket.get('messages', []): message['created_display'] = _support_time(message.get('created_at')) message['image_url'] = url_for('support_image', env_id=env_id, ticket_id=ticket.get('id'), filename=message['image']) if message.get('image') else None if selected: selected = next(ticket for ticket in tickets if ticket.get('id') == selected.get('id')) base_url = _user_support_url(env_id) return render_template_string( SUPPORT_PORTAL_TEMPLATE, lang=lang, t=t, languages=SUPPORT_LANGUAGE_NAMES, env_id=env_id, env_name=env_name, tickets=tickets, selected_ticket=selected, support_blocked=blocked, base_url=base_url, back_url=back_url ) @app.route('/support', methods=['GET', 'POST']) def general_support(): return _handle_support_portal('general') @app.route('/env//support', methods=['GET', 'POST']) def support_portal(env_id): return _handle_support_portal(env_id) def _handle_support_portal(env_id): if request.method == 'POST': lang = _support_lang() t = _support_texts(lang) actor_id = _support_actor_id() if not _valid_support_env_id(env_id): return 'Support page not found.', 404 action = request.form.get('action', 'new') submitted_ticket_id = request.form.get('ticket_id', '') pending_ticket_id = submitted_ticket_id if action == 'reply' and re.fullmatch(r'[a-f0-9]{32}', submitted_ticket_id) else uuid.uuid4().hex attachment = None try: if request.files.get('image') and request.files['image'].filename: # Store each attachment beneath an unguessable ticket ID. attachment = _save_support_image(request.files['image'], env_id, pending_ticket_id) except SupportUploadError as exc: flash(t.get(exc.key, t['invalid_image']), 'error') return redirect(_user_support_url(env_id, lang=lang, ticket=submitted_ticket_id)) text = request.form.get('message', '').strip() if not text and not attachment: flash(t['message_required'], 'error') return redirect(_user_support_url(env_id, lang=lang, ticket=request.form.get('ticket_id', ''))) with data_lock: data = load_data() if env_id != 'general' and not isinstance(data.get(env_id), dict): return 'Environment not found.', 404 bucket = _ensure_support_bucket(data, env_id) if bucket is None: return 'Environment not found.', 404 if bucket.get('support_blocked'): flash(t['support_disabled'], 'error') return redirect(_user_support_url(env_id, lang=lang, ticket=request.form.get('ticket_id', ''))) now = datetime.utcnow().isoformat() if action == 'new': ticket_id = pending_ticket_id subject = request.form.get('subject', '').strip()[:120] if not subject: subject = (text[:74] + ('…' if len(text) > 74 else '')) if text else t['attached_image'] message = {'sender':'user','text':text,'created_at':now,'read_by_admin':False,'read_by_user':True,'image':attachment} ticket = {'id':ticket_id,'actor_id':actor_id,'subject':subject,'status':'open','created_at':now,'updated_at':now,'messages':[message]} bucket['support_tickets'].append(ticket) elif action == 'reply': ticket_id = request.form.get('ticket_id', '') ticket = _ticket_for_actor(bucket, ticket_id, actor_id) if not ticket: flash(t['ticket_not_found'], 'error') return redirect(_user_support_url(env_id, lang=lang)) if ticket.get('status') == 'closed': flash(t['closed'], 'error') return redirect(_user_support_url(env_id, lang=lang, ticket=ticket_id)) ticket['messages'].append({'sender':'user','text':text,'created_at':now,'read_by_admin':False,'read_by_user':True,'image':attachment}) ticket['updated_at'] = now else: flash(t['send_failed'], 'error') return redirect(_user_support_url(env_id, lang=lang)) save_data(data) flash(t['ticket_saved'], 'success') return redirect(_user_support_url(env_id, lang=lang, ticket=ticket_id)) return _render_support_portal(env_id) @app.route('/support-media///') def support_image(env_id, ticket_id, filename): if not _valid_support_env_id(env_id) or not re.fullmatch(r'[a-f0-9]{32}', ticket_id) or not re.fullmatch(r'[a-f0-9]{32}\.(?:jpg|jpeg|png|gif|webp)', filename): return 'Not found.', 404 with data_lock: data = load_data() bucket = data.get(GLOBAL_SUPPORT_KEY) if env_id == 'general' else data.get(env_id) ticket = _find_support_ticket(bucket, ticket_id) allowed = bool(ticket and any(message.get('image') == filename for message in ticket.get('messages', []) if isinstance(message, dict))) if not allowed: return 'Not found.', 404 local_path = os.path.abspath(os.path.join(SUPPORT_MEDIA_DIR, env_id, ticket_id, filename)) if not os.path.isfile(local_path) and (HF_TOKEN_READ or HF_TOKEN_WRITE): try: repo_path = '/'.join(('support_media', env_id, ticket_id, filename)) local_path = hf_hub_download(repo_id=REPO_ID, filename=repo_path, repo_type='dataset', token=HF_TOKEN_READ or HF_TOKEN_WRITE, local_dir='.', local_dir_use_symlinks=False) except Exception as exc: logging.warning('Could not fetch support attachment: %s', exc) if not os.path.isfile(local_path): return 'Not found.', 404 return send_file(local_path, mimetype=mimetypes.guess_type(filename)[0] or 'application/octet-stream', conditional=True) @app.route('/admhosto/support', methods=['GET']) def admhosto_support(): lang = _support_lang() t = _support_texts(lang) env_filter = request.args.get('env_id', '') with data_lock: data = load_data() snapshot = deepcopy(data) env_options = [{'id':'','label':'Все среды'}, {'id':'general','label':'Общая поддержка'}] for env_id, env_data in snapshot.items(): if env_id == GLOBAL_SUPPORT_KEY or not _valid_support_env_id(env_id) or not isinstance(env_data, dict): continue env_options.append({'id':env_id,'label':f"{env_id} — {env_data.get('keyword') or 'Без названия'}"}) items = [] for env_id, bucket in snapshot.items(): if env_id == GLOBAL_SUPPORT_KEY: actual_env_id = 'general' elif _valid_support_env_id(env_id): actual_env_id = env_id else: continue if env_filter and env_filter != actual_env_id: continue if not isinstance(bucket, dict): continue general_label = {'ru':'Общая поддержка','uk':'Загальна підтримка','pl':'Wsparcie ogólne','en':'General support','tr':'Genel destek','zh':'常规支持','vi':'Hỗ trợ chung'}.get(lang,'General support') env_label = general_label if actual_env_id == 'general' else f"{actual_env_id} — {bucket.get('keyword') or 'Без названия'}" for ticket in bucket.get('support_tickets', []): if not isinstance(ticket, dict): continue messages = ticket.get('messages', []) latest = messages[-1] if messages else {} preview = (latest.get('text') or t['attached_image']).strip() items.append({ 'id':ticket.get('id'), 'env_id':actual_env_id, 'env_label':env_label, 'subject':ticket.get('subject') or t['new_request'], 'status':ticket.get('status','open'), 'updated_at':ticket.get('updated_at',ticket.get('created_at','')), 'updated_display':_support_time(ticket.get('updated_at',ticket.get('created_at'))), 'preview':preview, 'unread_count':_unread_in_bucket({'support_tickets':[ticket]},'admin'), 'href':url_for('admhosto_support_thread',env_id=actual_env_id,ticket_id=ticket.get('id'),lang=lang) }) items.sort(key=lambda item:item.get('updated_at',''), reverse=True) back_label = 'Панель управления' if lang == 'ru' else {'uk':'Панель керування','pl':'Panel administracyjny','tr':'Yönetim paneli','zh':'管理面板','vi':'Bảng quản trị'}.get(lang,'Admin dashboard') filter_label = {'ru':'Фильтр по среде','uk':'Фільтр за середовищем','pl':'Filtr środowiska','en':'Filter by environment','tr':'Ortama göre filtrele','zh':'按环境筛选','vi':'Lọc theo môi trường'}.get(lang,'Filter by environment') for option in env_options: if option['id'] == '': option['label'] = {'ru':'Все среды','uk':'Усі середовища','pl':'Wszystkie środowiska','en':'All environments','tr':'Tüm ortamlar','zh':'所有环境','vi':'Tất cả môi trường'}.get(lang,'All environments') elif option['id'] == 'general': option['label'] = {'ru':'Общая поддержка','uk':'Загальна підтримка','pl':'Wsparcie ogólne','en':'General support','tr':'Genel destek','zh':'常规支持','vi':'Hỗ trợ chung'}.get(lang,'General support') return render_template_string(ADMHOSTO_SUPPORT_LIST_TEMPLATE, lang=lang, t=t, tickets=items, env_options=env_options, env_filter=env_filter, unread_total=_total_support_unread(snapshot,'admin'), back_label=back_label, filter_label=filter_label, languages=SUPPORT_LANGUAGE_NAMES) @app.route('/admhosto/support/toggle/', methods=['POST']) def admhosto_toggle_support(env_id): lang = _support_lang() t = _support_texts(lang) if not _valid_support_env_id(env_id): return 'Environment not found.', 404 with data_lock: data = load_data() bucket = _ensure_support_bucket(data, env_id) if bucket is None: return 'Environment not found.', 404 bucket['support_blocked'] = not bool(bucket.get('support_blocked')) save_data(data) blocked = bucket['support_blocked'] flash(t['disable_support'] if blocked else t['enable_support'], 'success') ticket_id = request.form.get('ticket_id') if ticket_id and re.fullmatch(r'[a-f0-9]{32}', ticket_id): return redirect(url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang)) return redirect(url_for('admhosto')) @app.route('/admhosto/support//', methods=['GET', 'POST']) def admhosto_support_thread(env_id, ticket_id): if not _valid_support_env_id(env_id) or not re.fullmatch(r'[a-f0-9]{32}', ticket_id): return 'Support request not found.', 404 lang = _support_lang() t = _support_texts(lang) if request.method == 'POST': action = request.form.get('action','reply') if action == 'toggle_status': with data_lock: data = load_data() bucket = data.get(GLOBAL_SUPPORT_KEY) if env_id == 'general' else data.get(env_id) ticket = _find_support_ticket(bucket, ticket_id) if not ticket: return 'Support request not found.', 404 ticket['status'] = 'open' if ticket.get('status') == 'closed' else 'closed' ticket['updated_at'] = datetime.utcnow().isoformat() save_data(data) return redirect(url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang)) if action != 'reply': return redirect(url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang)) text = request.form.get('message','').strip() attachment = None try: if request.files.get('image') and request.files['image'].filename: attachment = _save_support_image(request.files['image'], env_id, ticket_id) except SupportUploadError as exc: flash(t.get(exc.key,t['invalid_image']), 'error') return redirect(url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang)) if not text and not attachment: flash(t['message_required'], 'error') return redirect(url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang)) with data_lock: data = load_data() bucket = data.get(GLOBAL_SUPPORT_KEY) if env_id == 'general' else data.get(env_id) ticket = _find_support_ticket(bucket, ticket_id) if not ticket: return 'Support request not found.', 404 now = datetime.utcnow().isoformat() ticket.setdefault('messages', []).append({'sender':'admin','text':text,'created_at':now,'read_by_admin':True,'read_by_user':False,'image':attachment}) ticket['status'] = 'open' ticket['updated_at'] = now save_data(data) flash(t['reply_saved'], 'success') return redirect(url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang)) with data_lock: data = load_data() bucket = data.get(GLOBAL_SUPPORT_KEY) if env_id == 'general' else data.get(env_id) ticket = _find_support_ticket(bucket, ticket_id) if not ticket: return 'Support request not found.', 404 changed = False for message in ticket.get('messages', []): if message.get('sender') == 'user' and not message.get('read_by_admin', False): message['read_by_admin'] = True changed = True if changed: save_data(data) ticket_copy = deepcopy(ticket) blocked = bool(bucket.get('support_blocked', False)) if env_id == 'general': env_label = {'ru':'Общая поддержка','uk':'Загальна підтримка','pl':'Wsparcie ogólne','en':'General support','tr':'Genel destek','zh':'常规支持','vi':'Hỗ trợ chung'}.get(lang,'General support') else: env_label = f"{env_id} — {(data.get(env_id) or {}).get('keyword') or 'Без названия'}" ticket_copy['created_display'] = _support_time(ticket_copy.get('created_at')) for message in ticket_copy.get('messages', []): message['created_display'] = _support_time(message.get('created_at')) message['image_url'] = url_for('support_image', env_id=env_id, ticket_id=ticket_id, filename=message['image']) if message.get('image') else None thread_url = url_for('admhosto_support_thread', env_id=env_id, ticket_id=ticket_id, lang=lang) queue_url = url_for('admhosto_support', env_id=env_id, lang=lang) return render_template_string(ADMHOSTO_SUPPORT_THREAD_TEMPLATE, lang=lang, t=t, ticket=ticket_copy, env_id=env_id, env_label=env_label, support_blocked=blocked, thread_url=thread_url, queue_url=queue_url, languages=SUPPORT_LANGUAGE_NAMES) if __name__ == '__main__': setup_initial_files() download_db_from_hf() if HF_TOKEN_WRITE: backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() port = int(os.environ.get('PORT', 7860)) app.run(debug=False, host='0.0.0.0', port=port)