guard()) { return $guard; } $siteData = $this->siteRow(); $theme = (string) $siteData->theme; $catalogue = config('site_modules.catalogue'); $rules = config('site_modules.themes.' . $theme); if (! $rules) { return redirect()->route('site.theme.themes.r')->with('error', __('site.page_customize_theme_unknown')); } /* Esta tela salva apagando e recriando os blocos. Lista vazia por falha da API viraria layout apagado no primeiro salvar, entao aqui a falha barra a tela em vez de desenhar um site sem nada. */ $answer = $this->api->get('modules', ['action' => 'list']); if (($answer['ok'] ?? false) !== true) { return redirect()->route('site.theme.themes.r')->with('error', __('site.page_customize_api_error')); } $modules = collect($answer['modules'] ?? [])->map(fn ($row) => (object) $row); $placed = []; $seq = 1; foreach ($modules as $mod) { $item = $catalogue[$mod->type] ?? null; $rule = $rules['modules'][$mod->type] ?? null; // modulo que o tema atual nao conhece nao pode ser desenhado na grade if (! $item || ! $rule) { continue; } $placed[$mod->pos][$mod->id] = [ 'id' => $seq++, 'actualID' => $mod->id, 'identifier' => $item['slug'], 'moduleID' => $mod->type, 'accept' => $rule['accept'], 'size' => $mod->size, 'reduced' => $mod->reduced, /** * A configuracao do modulo vai junto para o navegador. * Antes ela chegava dentro do html que o painel devolvia por * modulo; com a previa vindo do tema, esse html e do tema e * nao carrega mais o dado. Sem isto o formulario de editar * abre vazio e, pior, o salvar grava todo modulo sem * configuracao, porque e daqui que ele le. */ 'data' => (string) $mod->data, 'resizable' => $this->canResize($theme, (int) $mod->pos, $item), ]; } /* The screen carries the site it was opened for and what it was built on: saves check both */ $guard = [ 'siteKey' => $this->siteKey(), 'revision' => (new SiteLayout((int) session('serverID'), (int) session('siteID'), $theme))->revision(), ]; // a versao de app tem grade propria e ainda usa a tela antiga if (Config::get('constants.IS_APP') || $siteData->stream_port == '7338') { return view('site.themes.' . $theme . 'Mobile', $this->shared() + $guard + [ 'isMobile' => (new Agent())->isMobile() ? 'true' : 'false', 'actualModules' => json_encode($placed, JSON_NUMERIC_CHECK), 'totalModules' => $modules->count(), ]); } return view('site.themes.customize', $this->shared() + $guard + [ 'themeCode' => $theme, 'colors' => $rules['colors'], /** * Quais modulos o cliente configura pelo formulario. * * Modulo vazio ganha um aviso, e o aviso manda editar. Nos que se * preenchem sozinhos, como o de aplicativos, editar so mostra o * titulo: mandar o cliente para la e mandar procurar o que nao * existe. */ 'picksContent' => array_keys(array_filter($catalogue, fn ($m) => $m['picks'])), /** * O nome de cada modulo, para o aviso de vazio. * * Modulo que nao desenha cabecalho, porque o cliente deixou o * titulo em branco, e nao tem conteudo ainda vira uma faixa anonima * na montagem: da para ver que ha algo ali e nao o que e. */ 'moduleNames' => array_map(fn ($m) => __($m['label']), $catalogue), // quantos modulos cada area tem: e a forma que o esqueleto imita enquanto carrega 'filled' => array_map('count', $placed), 'containers' => $this->visibleContainers($rules['containers'], $siteData), 'palette' => $this->palette($rules['modules'], $catalogue, $siteData), 'isMobile' => (new Agent())->isMobile() ? 'true' : 'false', 'actualModules' => json_encode($placed, JSON_NUMERIC_CHECK), 'totalModules' => $modules->count(), ]); } /** * Previa dos modulos, desenhada pelo proprio tema. * * O painel tinha 108 views so para imitar como cada modulo aparece no * site, de uma geracao antiga dos temas: as duas partes nao compartilhavam * uma classe sequer, entao a previa mentia. Agora quem responde e o tema, * pelo mesmo loadModule() que monta a pagina de verdade. * * Uma chamada leva a pagina inteira. Antes era uma requisicao por modulo, * cada uma com suas consultas, e uma grade cheia levava segundos. */ public function previewModules(Request $request) { if ($guard = $this->guard()) { return $guard; } if ($refusal = $this->siteMismatch($request, false)) { return $refusal; } $modules = (array) $request->input('modules', []); $palette = (array) $request->input('palette', []); $key = (string) config('siteapi.preview_key'); // trocar uma cor nao precisa do html dos modulos, so da folha nova if (! $modules && ! $palette) { return response()->json(['ok' => false, 'error' => 'modules_required'], 422); } if (! $key) { return response()->json(['ok' => false, 'error' => 'not_configured'], 503); } $site = $this->siteRow(); try { $response = (new Client())->post('https://' . $site->domain . '/app/preview-modules.php', [ 'json' => ['key' => $key, 'modules' => array_slice($modules, 0, 60), 'palette' => $palette], 'timeout' => config('siteapi.preview_timeout'), 'verify' => config('siteapi.verify_ssl'), 'headers' => ['Accept' => 'application/json'], ]); $result = json_decode($response->getBody()->getContents(), true); } catch (Throwable $e) { Log::warning('module preview failed', [ 'site_id' => session('siteID'), 'error' => $e->getMessage(), ]); return response()->json(['ok' => false, 'error' => 'unreachable'], 502); } if (($result['ok'] ?? false) !== true) { return response()->json(['ok' => false, 'error' => $result['error'] ?? 'failed'], 502); } return response()->json($result); } /** * Modulo so muda de largura na coluna do meio, e apenas nos tipos que * aceitam. Era uma expressao dentro do laco, com '1' onde devia ser '01'. */ private function canResize(string $theme, int $pos, array $item): bool { $column = $theme === '01' ? 3 : 2; return $pos === $column && $item['resizable']; } /** Areas que este site enxerga: a de TV so existe em site de TV. */ private function visibleContainers(array $containers, $siteData): array { return array_values(array_filter($containers, fn ($container) => $this->allowedFor($container['when'] ?? null, $siteData))); } /** Paleta do tema, na ordem do catalogo e sem o que nao serve a este site. */ private function palette(array $rules, array $catalogue, $siteData): array { $out = []; foreach ($rules as $id => $rule) { if (! isset($catalogue[$id]) || ! $this->allowedFor($rule['when'] ?? null, $siteData)) { continue; } $out[] = $catalogue[$id] + $rule + ['id' => $id]; } return $out; } private function allowedFor(?string $when, $siteData): bool { return match ($when) { 'radio' => ! $siteData->stream_tv, 'tv' => $siteData->stream_tv && $siteData->stream_tv_name, 'port' => (bool) $siteData->stream_port, default => true, }; } /* Preview only: it used to write the module too, against ids a save had already replaced */ public function getModule(Request $request, $id = null) { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } if ($refusal = $this->siteMismatch($request, false)) { return $refusal; } if(!$id || $id == 'undefined'){ return false; } parse_str($request->data, $output); $show = (isset($output['extra']) && isset($output['extra']['hide_title']) ? false : 'true'); $serverData = DB::table('sites_servers')->where('id','=',session('serverID'))->get()->first(); $siteData = SiteContext::current(); $titleArray = array_map(fn ($item) => $item['title'], config('site_modules.catalogue')); $qtdArray = array_map(fn ($item) => $item['quantity'], config('site_modules.catalogue')); $answer = $this->api->get('modules', [ 'action' => 'get', 'id_row' => (int) $request->actual, 'type' => (string) $id, ]); $module = ($answer['module'] ?? null) ? (object) $answer['module'] : null; if($module){parse_str($module->data, $dataMod);} $titleMod = isset($dataMod['title']) ? $dataMod['title'] : $titleArray[$id]; $title = isset($output['title']) ? $output['title'] : $titleMod; /* BUILD MENU */ $arrPagesDefault = array('/','/programacao','/albuns','/videos','/recados','/locutores','/posts','/contato'); $answer = $this->api->get('pages', [ 'action' => 'list', 'urls' => implode(',', $arrPagesDefault), 'limit' => count($arrPagesDefault), ]); $arrLinks = []; foreach(($answer['items'] ?? []) as $page){ array_push($arrLinks,$page['id']); } $queryMenu = http_build_query(array('entities' => $arrLinks)); /* BUILD MENU */ $chosen = $request->data ? $request->data : ($module && !empty($module->data) ? $module->data : ($id == '14' ? 'title='.$titleArray[$id].'&'.$queryMenu : 'title='.$titleArray[$id].'&entityId=0&quantity='.$qtdArray[$id])); return view('site.modules.'.$siteData->theme.'.'.$id, $this->moduleData((int) $id, $chosen, (string) $siteData->theme) + ['siteID' => session('siteID'), 'server' => $serverData, 'show' => ($request->actual == 0 ? 'true' : (!$module ? 'false' : (isset($dataMod['extra']) ? 'false' : $show))), 'title' => $title, 'data' => $chosen]); } /** * O dado que a previa de cada modulo desenha. * * Antes cada view abria o banco do servidor de sites por dentro do HTML. * Agora o controller pergunta a API e entrega pronto, com os mesmos nomes * de variavel que a view ja usava, para o desenho nao mudar. * * @return array */ private function moduleData(int $id, string $raw, string $theme): array { parse_str($raw, $chosen); $entity = (int) ($chosen['entityId'] ?? 0); $quantity = (int) ($chosen['quantity'] ?? 0); return match ($id) { /* modulo livre e publicidade: a escolha e uma linha so, e a API resolve pelo site da sessao. A consulta antiga buscava so pelo id, entao um id de outro site desenhava o item dele */ 1 => ['freeM' => $this->oneRow('entities', ['action' => 'get', 'type' => 'FreeModule', 'id_row' => $entity], 'item')], 2 => ['ads' => $this->oneRow('ads', ['action' => 'get', 'id_row' => $entity], 'ad')], 12 => $this->pollData($entity), 13 => ['social' => (object) ((array) ($this->api->get('profile', ['action' => 'get'])['social'] ?? []))], 16 => ['topList' => $this->topList($quantity)], 17 => ['stats' => (object) ((array) ($this->api->get('settings', ['action' => 'get'])['site'] ?? []))], /* eventos, promocoes, albuns e videos: a previa desenha os mais recentes, que e o que a lista sempre mostrou */ 3 => ['events' => $this->rows('events', ['action' => 'list', 'sort' => 'recent', 'limit' => 1])], 4 => ['promotions' => $this->promotions($quantity)], 6 => $this->albumData(), 10 => ['videos' => $this->rows('videos', ['action' => 'list', 'sort' => 'recent', 'limit' => 2])], 11 => $this->linkData(), /* a capa dos temas 02 e 04 desenha tambem as redes e quem esta no ar */ 15 => ['setData' => (object) ['cover' => $this->mediaField('cover')], 'social' => (object) ((array) ($this->api->get('profile', ['action' => 'get'])['social'] ?? []))] + $this->onAirData(), 18 => ['onAir' => (object) ['musicrequest' => $this->mediaField('musicrequest')]], 5, 24 => $this->onAirData(), 7 => $this->featuredData(), 8 => $this->postsModule($entity, $quantity, $theme), 9 => ['messages' => $this->rows('messages', ['action' => 'approved', 'limit' => max(1, $quantity)])], 14 => ['pageTitles' => $this->pageTitles($raw)], 25 => ['news' => $this->newsData($raw, $quantity)], 27 => $this->podcastData($raw, $entity), default => [], }; } /** * O programa que esta no ar agora, com o locutor. * * O dia e a hora saem daqui, do fuso da radio, porque e assim que a tela * ja decidia. A previa do locutor online consultava a grade sem site_id e * desenhava o programa de outro site que estivesse no ar. */ private function onAirData(): array { $slot = $this->api->get('scheduling', [ 'action' => 'on_air', 'day' => date('w'), 'time' => date('H:i'), ])['slot'] ?? null; if (! $slot) { return ['onAir' => collect(), 'onAir_program' => null, 'onAir_user' => null]; } $program = (object) [ 'id' => $slot['program_id'] ?? null, 'name' => $slot['program'] ?? null, 'img' => $slot['program_img'] ?? null, 'user_id' => $slot['host_id'] ?? null, ]; $host = empty($slot['host_id']) ? null : (object) [ 'id' => $slot['host_id'], 'name' => $slot['host'] ?? null, 'avatar' => $slot['host_avatar'] ?? null, ]; return [ 'onAir' => collect([(object) $slot]), 'onAir_program' => $program, 'onAir_user' => $host, ]; } /** * O destaque mais recente, com o titulo e a imagem do que ele aponta. * * A API ja resolve o destino de cada tipo (noticia, evento, promocao ou o * proprio destaque), entao aqui e so devolver com os nomes de campo que a * view le, que mudam conforme o tipo. */ private function featuredData(): array { $items = $this->rows('featured', ['action' => 'list', 'limit' => 100]) ->sortByDesc('id')->values(); if ($items->isEmpty()) { return ['featured' => $items, 'dataFeature' => null]; } $first = $items->first(); $column = ['0' => 'description', '1' => 'name', '2' => 'name', '3' => 'title'][(string) $first->type] ?? 'name'; $image = ['0' => 'img', '1' => 'img', '2' => 'img', '3' => 'feature_img'][(string) $first->type] ?? 'img'; return [ 'featured' => $items, 'dataFeature' => (object) [$column => $first->title ?? null, $image => $first->img ?? null], ]; } /** O titulo de cada pagina escolhida no menu, na ordem escolhida. */ private function pageTitles(string $raw): array { parse_str($raw, $chosen); $ids = array_values(array_filter(array_map('intval', (array) ($chosen['entities'] ?? [])))); if (! $ids) { return []; } $titles = []; foreach ($this->rows('pages', ['action' => 'list', 'ids' => implode(',', $ids), 'limit' => 100]) as $page) { $titles[(int) $page->id] = $page; } return $titles; } /** As novidades do tipo e da categoria escolhidos no modulo. */ private function newsData(string $raw, int $quantity) { parse_str($raw, $chosen); $query = ['action' => 'list', 'limit' => $quantity > 0 ? $quantity : 5]; if (isset($chosen['source']) && $chosen['source'] !== '') { $query['type'] = $chosen['source']; } if (isset($chosen['entityId']) && $chosen['entityId'] !== '') { $query['category'] = $chosen['entityId']; } return $this->rows('news-updated', $query); } /** * O podcast escolhido, ou o ultimo publicado, e quantos episodios ele tem. * * A contagem vem junto da lista (eps_live conta so os publicados), entao * nao ha uma segunda ida so para contar. */ private function podcastData(string $raw, int $entity): array { parse_str($raw, $chosen); $latest = isset($chosen['extra']['latest_podcast']) && $chosen['extra']['latest_podcast'] == 1; $items = $this->rows('podcasts', ['action' => 'list', 'limit' => 100]) ->filter(fn ($row) => (string) $row->status === '1')->values(); $podcast = $latest ? $items->first() : ($entity > 0 ? $items->firstWhere('id', $entity) : null); return ['podcast' => $podcast, 'epCount' => $podcast ? (int) ($podcast->eps_live ?? 0) : 0]; } /** * As noticias do modulo, que muda de regra conforme o tema. * * Nos temas 01 e 03 a categoria e obrigatoria e a situacao nao entra na * conta; nos temas 02 e 04 so as publicadas aparecem, a categoria e * opcional e o nome dela vai escrito acima da lista. */ private function postsModule(int $category, int $quantity, string $theme): array { $published = in_array($theme, ['02', '04'], true); if (! $published && $category < 1) { return ['news' => collect(), 'catName' => '']; } $query = ['action' => 'list', 'sort' => 'recent', 'limit' => max(1, $quantity)]; if ($category > 0) { $query['category'] = $category; } if ($published) { $query['published'] = '1'; } $name = ''; if ($category > 0) { $name = (string) ($this->api->get('entities', [ 'action' => 'get', 'type' => 'NewsCat', 'id_row' => $category, ])['item']['name'] ?? ''); } return ['news' => $this->rows('posts', $query), 'catName' => $name]; } /** As linhas de uma acao de lista, como colecao de objetos. */ private function rows(string $resource, array $query, string $key = 'items') { return collect(array_map( fn ($row) => (object) $row, (array) ($this->api->get($resource, $query)[$key] ?? []) )); } /** * As promocoes da previa, as primeiras cadastradas. * * A consulta antiga nao tinha ordem e o banco devolvia na ordem da chave; * a ordenacao por id crescente aqui e a mesma coisa, agora dita. */ private function promotions(int $quantity) { $items = $this->rows('promotions', ['action' => 'list', 'limit' => 100]) ->sortBy('id')->values(); return $quantity > 0 ? $items->take($quantity) : $items; } /** Os dois ultimos albuns e as fotos do primeiro deles. */ private function albumData(): array { $albums = $this->rows('albums', ['action' => 'list', 'sort' => 'recent', 'limit' => 2]); $photos = []; foreach ($albums as $album) { $photos[(int) $album->id] = $this->rows('albums', [ 'action' => 'photos', 'id_row' => (int) $album->id, 'limit' => 100, ]); } return ['albums' => $albums, 'albumPhotos' => $photos]; } /** Os links ligados, na ordem em que foram criados, e o total de todos. */ private function linkData(): array { $answer = $this->api->get('links', ['action' => 'list', 'limit' => 100]); $links = collect(array_map(fn ($row) => (object) $row, (array) ($answer['items'] ?? []))) ->filter(fn ($row) => (string) $row->active === '1') ->sortBy('id')->values(); return ['total' => (int) ($answer['total'] ?? 0), 'getLinks' => $links]; } /** O arquivo guardado num dos dois campos de midia das configuracoes. */ private function mediaField(string $field): ?string { $answer = $this->api->get('settings', ['action' => 'media', 'field' => $field]); return ($answer['ok'] ?? false) === true ? ($answer['value'] ?? null) : null; } /** Uma linha da API como a previa espera: colecao de zero ou uma. */ private function oneRow(string $resource, array $query, string $key) { if ((int) ($query['id_row'] ?? 0) < 1) { return collect(); } $row = $this->api->get($resource, $query)[$key] ?? null; return collect($row ? [(object) $row] : []); } /** A enquete escolhida e as opcoes dela, na mesma chamada. */ private function pollData(int $entity): array { if ($entity < 1) { return ['poll' => collect(), 'poll_options' => collect()]; } $answer = $this->api->get('polls', ['action' => 'get', 'id_row' => $entity]); $poll = $answer['poll'] ?? null; return [ 'poll' => collect($poll ? [(object) $poll] : []), 'poll_options' => collect(array_map(fn ($row) => (object) $row, (array) ($answer['options'] ?? []))), ]; } /** * As musicas do top, ate a quantidade escolhida no modulo. * * A API devolve a lista inteira ja na ordem (ord, depois id), e o corte * fica aqui: e uma lista curta por natureza e assim a acao continua * servindo a tela do painel sem parametro novo. */ private function topList(int $quantity) { $items = (array) ($this->api->get('top', ['action' => 'list'])['items'] ?? []); if ($quantity > 0) { $items = array_slice($items, 0, $quantity); } return collect(array_map(fn ($row) => (object) $row, $items)); } public function editModule(Request $request) { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } if ($refusal = $this->siteMismatch($request, false)) { return $refusal; } $serverData = DB::table('sites_servers')->where('id','=',session('serverID'))->get()->first(); $siteData = SiteContext::current(); parse_str((string) $request->data, $reqMod); /* A module without settings yet gets the catalogue defaults, never another module's (its entityId included) */ $item = config('site_modules.catalogue.' . (int) $request->type, []); $defaults = ['title' => (string) ($item['title'] ?? ''), 'entityId' => '0', 'quantity' => (string) ($item['quantity'] ?? '0')]; $current = $reqMod ?: $defaults; $currentRaw = $request->data ?: http_build_query($defaults); return view('site.modules-edit.' . $siteData->theme . '.' . $request->type, [ 'siteID' => session('siteID'), 'server' => $serverData, 'title' => $current['title'] ?? '', 'qtd' => $current['quantity'] ?? 0, 'extra' => isset($current['extra']) ? 'hide' : '', 'data' => $current, 'reqData' => $currentRaw, ]); } /** * Nothing is written unless the whole layout is valid, belongs to the site * the screen was opened for, and was built on what is stored right now. */ public function save(Request $request) { if (Auth::check() === false) { return response()->json(['ok' => false, 'error' => 'auth'], 401); } if ($refusal = $this->siteMismatch($request, true)) { return $refusal; } $layout = new SiteLayout((int) session('serverID'), (int) session('siteID'), (string) $this->siteRow()->theme); $parsed = $layout->parse((string) $request->input('data')); if (is_string($parsed)) { return response()->json(['ok' => false, 'error' => $parsed], 422); } $current = $layout->current(); $stored = $layout->revision($current); $incoming = $layout->fingerprint($parsed['modules'], $parsed['paint']); if (! hash_equals($stored, (string) $request->input('revision'))) { /* A retry after a timeout whose first attempt did get through */ if (hash_equals($stored, $incoming)) { return response()->json(['ok' => true, 'revision' => $stored, 'ids' => $this->refsToIds($parsed['modules'], $current)]); } return response()->json(['ok' => false, 'error' => 'stale'], 409); } /* A module that had settings arriving without any lost them on the way, not on purpose */ $before = []; foreach ($current as $row) { $before[(int) $row->id] = (string) $row->data; } foreach ($parsed['modules'] as $module) { if ($module['data'] === '' && ($before[$module['actual']] ?? '') !== '') { return response()->json(['ok' => false, 'error' => 'module_data_missing'], 422); } } $ids = $layout->save($parsed); $this->streamLog('Alterou as personalizações do site'); return response()->json(['ok' => true, 'revision' => $incoming, 'ids' => $ids]); } /** New ids for the screen when the save had already happened: same layout, same order. */ private function refsToIds(array $modules, array $current): array { $ids = []; foreach (array_values($modules) as $index => $module) { if ($module['ref'] !== '' && isset($current[$index])) { $ids[$module['ref']] = (int) $current[$index]->id; } } return $ids; } /** * The session holds one active site for every tab. A screen opened for one * site must not read or write another because the client switched sites * in a second tab: that is how ads ended up pointing at a neighbour's ids. */ private function siteMismatch(Request $request, bool $required) { $claimed = (string) ($request->header('X-Site') ?? $request->input('site', '')); if ($claimed === '' && ! $required) { return null; } if (! hash_equals($this->siteKey(), $claimed)) { return response()->json(['ok' => false, 'error' => 'site_changed'], 409); } return null; } private function siteKey(): string { return (int) session('serverID') . ':' . (int) session('siteID'); } /** * Player que toca a radio dentro do site. * * Nao confundir com /radio/players-widgets, que entrega codigo para * colocar em site de fora. Aqui e o player do proprio site. * * A lista do que pode ser escolhido mora na api: o tema inclui a pasta * players/player{NN} pelo numero da coluna, entao valor invalido quebra o * site. O painel antigo lia a tabela `themes` para montar esta tela e nem * usava o resultado. */ public function players() { if ($guard = $this->guard()) { return $guard; } $result = $this->api->get('appearance', ['action' => 'get']); return view('site.players', $this->shared() + [ 'player' => (string) ($result['player'] ?? '0'), 'allowed' => (array) ($result['players'] ?? []), 'apiError' => ($result['ok'] ?? false) === true ? null : __('site.page_bg_api_error'), ]); } public function playerDo(Request $request) { if ($guard = $this->guard()) { return $guard; } $code = $request->input('use_player') === '1' ? (string) $request->input('player') : '0'; $result = $this->api->post('appearance', ['action' => 'player', 'player' => $code]); if (($result['ok'] ?? false) !== true) { return redirect()->route('site.theme.players.r')->with('error', __('site.page_bg_api_error')); } UserLog::write('theme.player.update', ['entity' => 'theme', 'description' => $code]); $this->streamLog('Alterou o player do site'); return redirect()->route('site.theme.players.r')->with('success', $code === '0' ? __('site.page_players_off_saved') : __('site.page_players_saved')); } public function uploadAssets(Request $request) { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } if ($refusal = $this->siteMismatch($request, false)) { return $refusal; } $serverData = DB::table('sites_servers')->where('id','=',session('serverID'))->get()->first(); $siteData = SiteContext::current(); // the folder name is sent to the upload server, so it cannot come straight from the client if(!in_array($request->type, ['cover', 'musicrequest'], true)){ return response()->json(['success' => false], 422); } $field = $request->type == 'cover' ? 'cover' : 'musicrequest'; $current = $this->api->get('settings', ['action' => 'media', 'field' => $field]); $oldFile = $current['value'] ?? null; if($request->file('file')) { $imagedetails = getimagesize($_FILES['file']['tmp_name']); $client = new Client(); try { $response = $client->request("POST", 'https://' . $serverData->name . '/_apiBrasUpload/bras_beUplThumb.php', [ 'multipart' => [ [ 'name' => 'file', 'filename' => $request->file('file')->getClientOriginalName(), 'Mime-Type' => $request->file('file')->getMimeType(), 'contents' => fopen($request->file('file')->path(), 'r'), ], [ 'name' => 'ext', 'contents' => $request->file('file')->extension() ], [ 'name' => 'site_id', 'contents' => session('siteID') ], [ 'name' => 'md5', 'contents' => md5($siteData->name) ], [ 'name' => 'fld', 'contents' => $request->type ], [ 'name' => 'w', 'contents' => ($request->type == 'cover' ? ($siteData->theme == '02' ? '250' : '1300') : ''.$imagedetails[0].'') ], [ 'name' => 'h', 'contents' => ($request->type == 'cover' ? ($siteData->theme == '02' ? '100' : '260') : ''.$imagedetails[1].'') ], ] ]); } catch (\Throwable $e) { Log::warning('theme asset upload failed', [ 'site_id' => session('siteID'), 'type' => $request->type, 'error' => $e->getMessage(), ]); return response()->json(['success' => false], 502); } $response = $response->getBody()->getContents(); $saved = $this->api->post('settings', [ 'action' => 'media', 'field' => $field, 'value' => $response, ]); if (($saved['ok'] ?? false) !== true) { Log::warning('theme asset saved on the storage but not on the site', [ 'site_id' => session('siteID'), 'field' => $field, 'file' => $response, ]); return response()->json(['success' => false], 502); } // only after the new file exists and the row points to it SiteMedia::remove($serverData->name ?? null, $request->type, $oldFile, $response); UserLog::write('theme.' . $request->type . '.update', ['entity' => 'theme']); } } /** * Catalogo de temas. Os dados vao pela brasapi, ver [[project_painel_brasapi]]. * * A tela mostra o que o site pode ativar, mais o tema em uso. O que o * plano nao inclui nem aparece. */ public function themes() { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } $result = $this->api->get('themes', ['action' => 'list']); $items = collect($result['items'] ?? [])->map(fn ($row) => (object) $row); return view('site.themes-list', [ 'userLogged' => User::where('id', '=', Auth::id())->first(), 'allNotifications' => DB::table('notifications')->orderBy('id', 'desc')->limit(3)->get(), 'topHref' => 'site', 'siteData' => SiteContext::current(), 'allThemes' => $items, 'current' => (string) ($result['current'] ?? ''), 'unlisted' => (bool) ($result['unlisted'] ?? false), 'legacy' => (bool) ($result['legacy'] ?? false), // tema em uso que nao esta entre os ativaveis nao volta depois da troca 'noReturn' => $items->firstWhere('current', true)?->allowed === false, 'apiError' => ($result['ok'] ?? false) === true ? null : __('site.page_themes_api_error'), ]); } /** * Troca o tema do site. * * A troca nao e so gravar a coluna: cada tema aceita cada tipo de modulo * em um conjunto de posicoes, e um modulo numa posicao que o tema novo nao * aceita some do site sem aviso. Quem move esses modulos e a api, na mesma * transacao da troca, para nunca existir tema novo com posicao velha. */ public function changeTheme(Request $request) { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } $code = trim((string) $request->input('code', $request->input('id'))); $result = $this->api->post('themes', ['action' => 'change', 'code' => $code]); if (($result['ok'] ?? false) !== true) { $message = match ($result['error'] ?? '') { 'theme_not_allowed' => __('site.page_themes_error_plan'), 'theme_not_found', 'theme_invalid' => __('site.page_themes_error_gone'), default => __('site.page_themes_api_error'), }; return redirect()->route('site.theme.themes.r')->with('error', $message); } UserLog::write('theme.change', ['entity' => 'theme', 'description' => $code]); $siteData = SiteContext::current(); if ($siteData && $siteData->stream_port) { setLog($siteData->stream_port, 'Alterou o tema do site', request()->ip()); } $moved = (int) ($result['moved'] ?? 0); return redirect()->route('site.theme.themes.r')->with('success', $moved ? trans_choice('site.page_themes_changed_moved', $moved, ['n' => $moved]) : __('site.page_themes_changed')); } public function searchEnt($entry = null,Request $request) { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } if ($refusal = $this->siteMismatch($request, false)) { return $refusal; } $serverData = DB::table('sites_servers')->where('id','=',session('serverID'))->get()->first(); $search = ($request->q ? $request->q : null); /* O nome da pasta da imagem do anuncio ainda sai daqui, e o tipo precisa existir antes de virar pedido. */ $types = array('Ad' => 'ads', 'FreeModule' => 'free_module','Poll'=>'poll', 'NewsUpd'=>'news_updated_cat', 'NewsCat'=>'posts_categories', 'Podcasts' => 'podcasts'); if (! isset($types[$entry])) { return response()->json(['items' => []]); } /* Os filtros de cada tipo moram no endpoint: anuncio so dos tipos 0 e 1, podcast so publicado, e a lista de novidades e global. */ $answer = $this->api->get('entities', ['action' => 'search', 'type' => $entry, 'q' => $search]); $curArr = []; $i=0; foreach(($answer['items'] ?? []) as $data){ $curArr[$i]['id'] = $data['id']; $curArr[$i]['text'] = $data['name']; $curArr[$i]['entityImage'] = ($entry == 'Ad' ? array('path' => $types[$entry], 'image' =>(!empty($data['img']) ? 'https://'.$serverData->name.'/contents/'.session('siteID').'/ads/'.$data['img'] : 'https://app.brascast.com/assets/img/no-image.png')) : 'https://app.brascast.com/assets/img/no-image.png'); $i++; } return response()->json(['items' => $curArr]); } public function css() { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } $siteData = SiteContext::current(); if($siteData->theme == '03'){ $contents = '.color-1 { color: '.$siteData->p1.'; } .bg-color-1 { background-color: '.$siteData->p1.'; } .bg-color3 { background-color: '.$siteData->p3.'!important; } .border3 { border-color: '.$siteData->p3.'!important; } .font-size { font-size: '.$siteData->font.'px; } .color-2 { color: '.$siteData->p2.'; } .color-3 { color: '.$siteData->p3.'; } .color-4 { color: '.$siteData->p4.'; }'; }else{ $contents = '.color-1 { color: '.$siteData->p1.'; } .bg-color-1 { background-color: '.$siteData->p1.'; } .bg-color3 { background-color: '.$siteData->p3.'; } .border3 { border-color: '.$siteData->p3.'!important; } .font-size { font-size: '.$siteData->font.'px; } .color-2 { color: '.$siteData->p2.'; } .color-3 { color: '.$siteData->p3.'; } .color-4 { color: '.$siteData->p4.'; }'; } return response($contents)->header('Content-Type', 'text/css'); } /** * Fundo do site. Os dados vao pela brasapi, ver [[project_painel_brasapi]]. * * A imagem continua indo pelo painel para o servidor de sites, que e quem * redimensiona e guarda o arquivo. */ public function bg() { if ($guard = $this->guard()) { return $guard; } $result = $this->api->get('appearance', ['action' => 'get']); return view('site.theme-bg', $this->shared() + [ 'bg' => (object) ($result['background'] ?? []), 'apiError' => ($result['ok'] ?? false) === true ? null : __('site.page_bg_api_error'), ]); } public function bgDo(Request $request) { if ($guard = $this->guard()) { return $guard; } $result = $this->api->post('appearance', [ 'action' => 'background', 'img' => (string) $request->input('bg_img'), 'opacity' => (int) $request->input('opacity'), 'repeat' => (string) $request->input('repeat'), 'size' => $request->input('size') === '1' ? '1' : '0', 'position' => (string) $request->input('position'), ]); if (($result['ok'] ?? false) !== true) { return redirect()->route('site.theme.bg.u')->with('error', __('site.page_bg_api_error')); } SiteMedia::removeMany($this->server(), 'background', (array) ($result['files'] ?? [])); UserLog::write('theme.background.update', ['entity' => 'theme']); $this->streamLog('Alterou o fundo do site'); return redirect()->route('site.theme.bg.u')->with('success', __('site.page_bg_saved')); } public function bgRemove(Request $request) { if ($guard = $this->guard()) { return $guard; } $result = $this->api->post('appearance', ['action' => 'background_clear']); if (($result['ok'] ?? false) !== true) { return redirect()->route('site.theme.bg.u')->with('error', __('site.page_bg_api_error')); } SiteMedia::removeMany($this->server(), 'background', (array) ($result['files'] ?? [])); UserLog::write('theme.background.delete', ['entity' => 'theme']); $this->streamLog('Removeu o fundo do site'); return redirect()->route('site.theme.bg.u')->with('success', __('site.page_bg_removed')); } /** Envia a imagem de fundo e devolve o nome gravado, sem salvar ainda. */ public function bgUpload(Request $request) { if ($guard = $this->guard()) { return $guard; } $request->validate(['file' => 'required|image|max:102400']); return $this->uploadTo($request->file('file'), 'background', 1920, 1080); } public function icon() { if ($guard = $this->guard()) { return $guard; } $result = $this->api->get('appearance', ['action' => 'get']); return view('site.theme-icon', $this->shared() + [ 'icon' => (string) ($result['icon'] ?? ''), 'apiError' => ($result['ok'] ?? false) === true ? null : __('site.page_bg_api_error'), ]); } public function iconDo(Request $request) { if ($guard = $this->guard()) { return $guard; } $request->validate(['icon' => 'required|image|mimes:jpg,png,jpeg,webp|max:2048']); return $this->saveAsset($request->file('icon'), 'icon', 32, 32, 'theme.icon.update', 'Alterou o ícone do site'); } public function share() { if ($guard = $this->guard()) { return $guard; } $result = $this->api->get('appearance', ['action' => 'get']); return view('site.theme-share', $this->shared() + [ 'share' => (string) ($result['share'] ?? ''), 'apiError' => ($result['ok'] ?? false) === true ? null : __('site.page_bg_api_error'), ]); } public function shareDo(Request $request) { if ($guard = $this->guard()) { return $guard; } $request->validate(['share' => 'required|image|mimes:jpg,png,jpeg,webp|max:2048']); return $this->saveAsset($request->file('share'), 'share', 600, 315, 'theme.share.update', 'Alterou a imagem de compartilhamento'); } /** * Envia a imagem e grava a coluna, que e o mesmo caminho do icone e do * compartilhamento: muda so a pasta, o tamanho e o registro. */ private function saveAsset($file, string $kind, int $width, int $height, string $log, string $message) { $server = $this->server(); $name = SiteMedia::upload($server, $kind, $file, $width, $height, $this->siteRow()->name); if (! $name) { return response()->json(['success' => false, 'message' => __('site.page_bg_upload_error')], 502); } $result = $this->api->post('appearance', ['action' => $kind, 'file' => $name]); if (($result['ok'] ?? false) !== true) { return response()->json(['success' => false, 'message' => __('site.page_bg_api_error')], 502); } SiteMedia::removeMany($server, $kind, (array) ($result['files'] ?? [])); UserLog::write($log, ['entity' => 'theme']); $this->streamLog($message); return response()->json([ 'success' => true, 'file' => $name, 'url' => 'https://' . $server . '/contents/' . session('siteID') . '/' . $kind . '/' . $name, ]); } /** Sobe o arquivo e devolve onde ele ficou, sem tocar no banco. */ private function uploadTo($file, string $folder, int $width, int $height) { $server = $this->server(); $name = SiteMedia::upload($server, $folder, $file, $width, $height, $this->siteRow()->name); if (! $name) { return response()->json(['success' => false, 'message' => __('site.page_bg_upload_error')], 502); } return response()->json([ 'success' => true, 'file' => $name, 'url' => 'https://' . $server . '/contents/' . session('siteID') . '/' . $folder . '/' . $name, ]); } /** Registro no servidor de streaming, que e onde o suporte olha o historico. */ private function streamLog(string $message): void { $port = $this->siteRow()->stream_port ?? null; if ($port) { setLog($port, $message, request()->ip()); } } private function server(): ?string { return DB::table('sites_servers')->where('id', '=', session('serverID'))->value('name'); } private function siteRow() { /** * A memoria e do objeto, nao da funcao. * * Com `static` a linha do site fica guardada no processo inteiro: a * segunda chamada devolve o site da primeira mesmo com outra sessao. Em * um pedido por processo isso nao aparece, mas basta o mesmo processo * atender dois pedidos, como em teste ou em worker que fica de pe, para * o painel responder com os dados do site errado. */ if ($this->siteRowCache === null) { $this->siteRowCache = SiteContext::current(); } return $this->siteRowCache; } private function guard() { if (Auth::check() === false) { Auth::logout(); return redirect()->route('login'); } return null; } private function shared(): array { return [ 'userLogged' => User::where('id', '=', Auth::id())->first(), 'allNotifications' => DB::table('notifications')->orderBy('id', 'desc')->limit(3)->get(), 'topHref' => 'site', 'siteData' => $this->siteRow(), 'serverData' => DB::table('sites_servers')->where('id', '=', session('serverID'))->first(), ]; }}