Создаём неустаревающий Slack-инвайт
Зачем я сделала этот сервис
Slack-приглашения на бесплатном тарифе имеют срок жизни. Через 30 дней ссылка устаревает — и люди, которые переходят по ней позже, видят ошибку. Это создаёт две проблемы:
- Старые ссылки умирают — их приходится отслеживать и вручную менять. Если они упоминаются в нескольких местах, то это неудобно.
- Люди жалуются — они не могут зайти в Slack, потому что попали на неактуальный инвайт.
Мне хотелось, чтобы была одна стабильная ссылка вроде slack.myproject.com, которая всегда ведёт на актуальный инвайт. И чтобы обновлять его было можно без пересборки деплоя или ручной правки.
До того, как я собрала этот сервис, ссылку можно было менять только в репозитории на GitHub. Это было неудобно, поскольку за ссылку отвечает отдел маркетинга, а не разработчики. Они были вынуждены обращаться к кому-то из команды, чтобы обновить ссылку. Это создавало задержки и неудобства.
В результате моей работы, описанной в этой статье, получился маленький, но надёжный сервис, который решает задачу. Он состоит из нескольких частей: публичной страницы, админки, API, системы валидации и мониторинга. В этой статье я расскажу, как он устроен и как собрать такой же.
Его можно легко адаптировать для других задач, не только для инвайтов в Slack.
Как развернуть проект
Прежде чем перейти к коду, посмотрим, какие технологии используются и как всё запустить локально.
Проект построен на:
- Next.js 15 с App Router;
- Vercel Edge Functions для редиректов и API;
- Vercel Edge Config для хранения данных;
- Auth0 для авторизации;
- Tailwind CSS для стилизации.
Чтобы запустить проект локально:
- Создайте новый проект с помощью
create-next-appна Next.js
npx create-next-app@latest slack-invite-service --typescript --app
cd slack-invite-service
- Установите нужные пакеты:
pnpm add next-auth @auth0/nextjs-auth0 tailwindcss postcss autoprefixer
pnpm add -D typescript @types/node @types/react
Если планируете использовать Edge Config SDK, также установите нужный пакет:
pnpm add @vercel/edge-config
- Добавьте
.env.localсо всеми переменными (будут перечислены по ходу статьи). - Запустите локальный сервер:
pnpm dev
Теперь можно переходить по http://localhost:3000 и увидеть редирект (если ссылка настроена) или заглушку.
Структура проекта
Вот как выглядит структура файлов, отвечающих за логику сервиса:
app/
├── api/
│ ├── invite/route.ts // GET и POST для работы со ссылкой
│ ├── invite/validate/route.ts // POST для ручной валидации
│ ├── cron/check-invite/route.ts // Ежедневная проверка ссылки
│ └── auth/[...auth0]/route.ts // Обработка входа/выхода через Auth0
│
├── admin/page.tsx // Админка: просмотр и обновление ссылки
├── page.tsx // Публичная страница с редиректом
├── middleware.ts // Обработка редиректов и ошибок авторизации
lib/
├── invite-utils.ts // Чтение, запись и валидация ссылок
├── slack-notifications.ts // Уведомления в Slack
├── auth-utils.ts // Обёртки над сессией
Архитектура: из чего состоит сервис
Вот схема, как работает этот сервис:
┌──────────────┐ ┌──────────────────────┐
│ Пользователь │─────▶│ slack.myproject.com │
└──────────────┘ └──────────────────────┘
│
▼
[ Edge Function ]
│
┌─────────────┴──────────────┐
│ Проверка ссылки │
│ (валидна и активна?) │
└─────────────┬──────────────┘
│
┌────────────▼────────────┐
│ Slack-инвайт доступен? │
└────────────┬────────────┘
│ Да
▼
[ Редирект на ссылку ]
│
▼
[ Пользователь в Slack ]
│
└──────────────┐
▼
┌────────────────────────────────────┐
│ Cron-задача проверяет ссылку │
│ (срок действия, HEAD-запрос) │
└────────────────────────────────────┘
┌───────────────────────────────────────┐
│ Админка │
│ - Просмотр текущей ссылки │
│ - Добавление новой │
│ - Ручная проверка │
└───────────────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Auth0 авторизация по имейлу │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Edge Config (хранилище URL) │
└─────────────────────────────┘
А вот компоненты внутри:
1. Публичная страница (/)
На эту страницу попадает пользователь, переходя по ссылке slack.myproject.com. Она делает следующее:
- проверяет, есть ли активная ссылка;
- валидирует, не устарела ли она и работает ли вообще;
- если всё хорошо — делает редирект;
- если что-то пошло не так — показывает заглушку с сообщением.
2. Админка (/admin)
Сюда могут зайти только авторизованные пользователи. В админке можно:
- посмотреть текущую ссылку;
- проверить, сколько дней ей осталось;
- заменить на новую.
Авторизация работает через Auth0 и ограничена по домену. Я расскажу об этом подробнее в следующем разделе.
3. API
Вся работа с данными идёт через API:
GET /api/invite— получить текущую ссылку;POST /api/invite— сохранить новую ссылку (с проверкой валидности);POST /api/invite/validate— вручную проверить текущую ссылку.
4. Хранение данных
Все данные — текущая ссылка, дата её создания, флаг активности — хранятся в Vercel Edge Config. Это почти как key-value база с мгновенным доступом по всему миру. Для маленького сервиса — идеальное решение.
5. Валидация и срок жизни
Каждая ссылка живёт максимум 30 дней. Сервис автоматически проверяет:
- валидный ли формат (например, содержит
join.slack.com); - доступна ли ссылка по HEAD-запросу;
- не истёк ли срок жизни.
Если что-то пошло не так — она деактивируется. Флаг isActive становится false, и пользователь видит сообщение о том, что ссылка устарела.
6. Slack-уведомления
Сервис отправляет уведомления в Slack, чтобы команда была в курсе событий: при обновлении ссылки или её деактивации. Подробнее об этом — в разделе ниже.
7. Cron-задача
Каждое утро (или по другому расписанию) запускается cron-роут, который проверяет:
- сколько дней осталось у ссылки;
- не сломалась ли она;
- если что-то не так — деактивирует и присылает уведомление.
Аутентификация и защита доступа
Чтобы никто случайный не зашёл в админку и не заменил ссылку на фишинговую, я добавила авторизацию через Auth0.
Зачем нужен Auth0
- Безопасный вход через имейл.
- Можно ограничить доступ по домену (например, только
@mycompany.com). - Простая интеграция с Next.js.
Я использовала Auth0 SDK для Next.js и настроила логин через кнопку Log in, которая перенаправляет пользователя на форму входа.
Как создать приложение в Auth0
- Зарегистрируйтесь на https://auth0.com/ и создайте новый tenant (например,
myproject.eu.auth0.com). - В разделе Applications нажмите Create application.
- Назовите приложение, выберите Regular Web Application.
- В настройках Settings укажите:
Allowed Callback URLs:
http://localhost:3000/api/auth/callback
https://slack.myproject.com/api/auth/callback
Allowed Logout URLs:
http://localhost:3000/admin
https://slack.myproject.com/admin
-
Скопируйте
Client IDиClient Secret— они понадобятся в.env. -
Создайте переменные окружения:
AUTH0_SECRET=... # можно сгенерировать с помощью openssl
AUTH0_BASE_URL=http://localhost:3000
AUTH0_ISSUER_BASE_URL=https://your-tenant.auth0.com
AUTH0_CLIENT_ID=...
AUTH0_CLIENT_SECRET=...
Как ограничить доступ по домену
Ранее в Auth0 использовались Rules, но сейчас актуальный способ — через Actions:
- Перейдите в Actions → Library и нажмите Create Action → Build from scratch.
- Назовите экшен, например
RestrictDomain. - В коде вставьте следующее:
/**
* @param {Event} event - Details about the user and the context of the login.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event, api) => {
const email = event.user.email || '';
if (!email.endsWith('@yourdomain.com')) {
api.access.deny('Access denied: only corporate emails are allowed.');
}
};
Замените
@yourdomain.comна ваш корпоративный домен.
- Нажмите Deploy, чтобы задеплоить экшен.
- Перейдите в Actions → Triggers → Login (Post Login).
- Перетащите ваш экшен из правой панели в основное поле и нажмите Apply.
Теперь только пользователи с имейлами определённого домена смогут получить доступ к админке.
Обработка ошибок входа
Если при входе возникла ошибка (например, отказ в доступе), сервис перенаправляет пользователя обратно на /admin, добавляя в URL параметры error и error_description. Интерфейс показывает соответствующее сообщение:
Доступ запрещён. Используйте корпоративный имейл.
Хранение данных: зачем Vercel Edge Config
Когда я искала способ хранить одну ссылку и метаданные к ней, мне не хотелось разворачивать полноценную базу данных ради пары полей. Решение должно было быть:
- простым и бесплатным;
- с минимальной задержкой;
- работать хорошо в edge-функциях на Vercel.
Именно поэтому я выбрала Vercel Edge Config — это key-value хранилище, которое доступно глобально и почти мгновенно. Оно идеально подходит для хранения небольших конфигурационных данных.
Что именно я храню
interface InviteData {
url: string; // Ссылка на Slack-инвайт
createdAt: string; // Когда была создана
isActive: boolean; // Флаг активности
}
Эта структура записывается в Edge Config под одним ключом — например, current_invite.
Как я читаю данные
При чтении я сначала пробую использовать SDK от Vercel. Если он не сработал (такое бывает в edge-среде), перехожу на резервный план — REST API:
const data = await get<InviteData>(INVITE_KEY); // через SDK
Если SDK не отдал результат, выполняется fetch-запрос напрямую по URL https://api.vercel.com/v1/edge-config/..., с авторизацией через токен.
Если и это не сработает, я возвращаю безопасный fallback:
{
url: '',
createdAt: new Date().toISOString(),
isActive: false
}
Как я записываю данные
Для записи я использую PATCH-запрос через REST API. В теле запроса отправляется массив с операцией upsert, которая заменяет или создаёт значение по ключу:
{
items: [
{
operation: 'upsert',
key: 'current_invite',
value: inviteData,
},
];
}
Безопасность
Для всех REST-запросов я использую API Token от Vercel, который хранится в .env. Этот токен даёт доступ только к Edge Config и не затрагивает остальные части проекта.
Валидация Slack-ссылки и срок действия
Когда пользователь сохраняет новую ссылку в админке, важно убедиться, что она действительно рабочая. Для этого сервис проводит несколько проверок:
1. Проверка формата
Slack-инвайты бывают нескольких видов, но все они содержат поддомены вроде join.slack.com или slack.com/signup. Поэтому сначала я проверяю, что ссылка вообще выглядит как Slack-инвайт:
if (!url.includes('join.slack.com') && !url.includes('slack.com/signup')) {
return false;
}
2. Проверка доступности (HEAD-запрос)
Даже если ссылка выглядит правильно, она может быть уже недействительной. Поэтому я отправляю HEAD-запрос с таймаутом 10 секунд:
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
headers: { 'User-Agent': 'SlackInviteValidator/1.0' },
});
clearTimeout(timeout);
return res.ok;
Если HEAD-запрос вернул 2xx или 3xx — значит ссылка ещё активна.
3. Проверка срока действия
Slack-инвайты живут 30 дней. Я сохраняю дату создания и считаю разницу:
const created = new Date(invite.createdAt);
const now = new Date();
const daysPassed = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
const isExpired = daysPassed > 30;
Если ссылка устарела — она автоматически помечается как неактивная.
Когда и как происходит валидация
- При добавлении новой ссылки — если формат неправильный или HEAD-запрос не проходит, она не сохраняется.
- При каждом заходе на главную страницу — если ссылка есть, она валидируется и при необходимости деактивируется. Это важно, потому что Slack-инвайты могут протухнуть не только по времени (30 дней), но и по количеству использований (обычно это 400 вступлений). Поэтому HEAD-запрос на каждый заход — дополнительная гарантия.
- В админке — можно запустить проверку вручную.
- Через cron-задачу — каждый день ссылка перепроверяется автоматически.
Slack-уведомления
Сервис отправляет уведомления в Slack, чтобы не пропустить важные события:
- Новая ссылка сохранена — кто-то обновил инвайт. Важно чтобы команда знала, что ссылка изменилась.
- Ссылка устарела и была деактивирована — по итогам cron-проверки или при переходе пользователя по неактивной ссылке.
Для этого используется Slack Incoming Webhook.
Как настроить
- Перейдите на https://api.slack.com/apps и создайте новое приложение.
- В разделе Incoming Webhooks включите функциональность.
- Создайте webhook-URL для нужного канала (например,
#alerts). - Скопируйте URL и сохраните в
.env.local:
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
Где и как вызываются уведомления
Создайте файл lib/slack-notifications.ts со вспомогательными функциями:
export const SlackNotifications = {
async linkUpdated(url: string, email: string) {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🔗 Ссылка обновлена пользователем ${email}: ${url}`,
}),
});
},
async linkInvalid(url: string) {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `⚠️ Slack-инвайт недействителен и был отключён: ${url}`,
}),
});
},
};
linkUpdatedвызывается вPOST /api/invite, когда пользователь сохраняет новую ссылку;linkInvalidвызывается в cron-задаче, если ссылка устарела или перестала работать.
Ручная проверка ссылки (
POST /api/invite/validate) уведомление не отправляет — это просто утилита для админки.
Интерфейс и страницы
Сервис использует три ключевые страницы и промежуточный слой:
Публичная страница
Когда пользователь переходит по https://slack.myproject.com/, он попадает на эту страницу. Она делает следующее:
- получает ссылку через
GET /api/invite; - проверяет, активна ли она;
- валидирует HEAD-запросом;
- если всё хорошо — делает
redirect(); - если что-то пошло не так — рендерит компонент-заглушку с сообщением.
// app/page.tsx
import { redirect } from 'next/navigation';
import { readInviteData, validateSlackInviteLink } from '@/lib/invite-utils';
export default async function HomePage() {
const invite = await readInviteData();
if (invite?.url && invite.isActive) {
const isValid = await validateSlackInviteLink(invite.url);
if (isValid) {
redirect(invite.url);
}
}
return (
<main className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
<h1 className="text-2xl font-bold">Ссылка недоступна</h1>
<p className="mt-2">
Пожалуйста, свяжитесь с администратором сообщества.
</p>
</main>
);
}
Админка
Страница для управления ссылкой. Доступна только после входа через Auth0. Показывает текущую ссылку, оставшиеся дни и форму замены:
// app/admin/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useUser } from '@auth0/nextjs-auth0/client';
export default function AdminPage() {
const { user, error, isLoading } = useUser();
const [data, setData] = useState<any>(null);
useEffect(() => {
fetch('/api/invite')
.then((res) => res.json())
.then(setData);
}, []);
if (isLoading) return <p>Загрузка...</p>;
if (error) return <p>Ошибка: {error.message}</p>;
if (!user) return <a href="/api/auth/login">Войти</a>;
return (
<main className="p-4 max-w-xl mx-auto">
<h1 className="text-xl font-bold mb-2">Текущая ссылка</h1>
<pre className="p-2 bg-gray-100 rounded">{data?.url || 'Нет ссылки'}</pre>
<form className="mt-4">
{/* тут можно реализовать отправку новой ссылки */}
</form>
<a className="text-sm underline mt-4 block" href="/api/auth/logout">
Выйти
</a>
</main>
);
}
Middleware (middleware.ts)
Используется для проверки авторизации в админке. Если пользователь не авторизован — его редиректит на страницу входа:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith('/admin')) {
const loggedIn = req.cookies.has('appSession');
if (!loggedIn) {
return NextResponse.redirect(new URL('/api/auth/login', req.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin'],
};
API: работа с ссылками
Сервис использует отдельные маршруты в app/api для получения, записи и валидации Slack-инвайтов. Ниже — пошаговая реализация каждого из них.
GET /api/invite
Возвращает текущую ссылку (если она есть).
// app/api/invite/route.ts
import { readInviteData } from '@/lib/invite-utils';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const invite = await readInviteData();
return NextResponse.json(invite);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to read invite data' },
{ status: 500 }
);
}
}
Этот роут не требует авторизации и используется как публичный эндпоинт внутри системы.
POST /api/invite
Добавляет новую ссылку. Работает только для авторизованных пользователей.
Добавьте в тот же файл app/api/invite/route.ts второй хендлер:
// app/api/invite/route.ts
import { getSession } from '@auth0/nextjs-auth0';
import { writeInviteData, validateSlackInviteLink } from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
export async function POST(request: Request) {
try {
const { user } = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { url } = await request.json();
const isValid = await validateSlackInviteLink(url);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid Slack invitation URL' },
{ status: 400 }
);
}
const inviteData = {
url: url.trim(),
createdAt: new Date().toISOString(),
isActive: true,
};
await writeInviteData(inviteData);
await SlackNotifications.linkUpdated(url, user.email);
return NextResponse.json(inviteData);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to save invite data' },
{ status: 500 }
);
}
}
Здесь:
- сначала проверяется сессия;
- затем валидируется ссылка;
- если всё в порядке, ссылка сохраняется и отправляется уведомление в Slack.
POST /api/invite/validate
Позволяет вручную проверить текущую ссылку. Если ссылка уже недействительна, она будет деактивирована. Этот эндпоинт не отправляет уведомление в Slack — он предназначен только для проверки из админки.
// app/api/invite/validate/route.ts
import { getSession } from '@auth0/nextjs-auth0';
import {
readInviteData,
validateSlackInviteLink,
getDaysLeft,
writeInviteData,
} from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
import { NextResponse } from 'next/server';
export async function POST() {
try {
const { user } = await getSession();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const invite = await readInviteData();
if (!invite.url) {
return NextResponse.json(
{ error: 'No invitation URL found' },
{ status: 404 }
);
}
const isValid = await validateSlackInviteLink(invite.url);
if (!isValid) {
const updatedInvite = { ...invite, isActive: false };
await writeInviteData(updatedInvite);
return NextResponse.json(
{ error: 'Link is no longer valid and has been deactivated' },
{ status: 400 }
);
}
return NextResponse.json({
message: 'Link is valid and active',
daysLeft: getDaysLeft(invite.createdAt),
});
} catch (error) {
return NextResponse.json({ error: 'Validation failed' }, { status: 500 });
}
}
Этот эндпоинт удобно вызывать из админки, чтобы проверить: не протухла ли ссылка до истечения 30 дней.
GET /api/cron/check-invite
Этот роут вызывается по расписанию (например, через встроенные cron-задачи Vercel) и проверяет текущую ссылку:
- сколько дней осталось до её истечения;
- активна ли она вообще;
- если всё плохо — отключает ссылку и шлёт уведомление в Slack.
import {
readInviteData,
validateSlackInviteLink,
getDaysLeft,
writeInviteData,
} from '@/lib/invite-utils';
import { SlackNotifications } from '@/lib/slack';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const invite = await readInviteData();
if (!invite.url || !invite.isActive) {
return NextResponse.json({ message: 'No active invite to check' });
}
const isValid = await validateSlackInviteLink(invite.url);
const daysLeft = getDaysLeft(invite.createdAt);
if (!isValid || daysLeft <= 0) {
await writeInviteData({ ...invite, isActive: false });
await SlackNotifications.linkInvalid(invite.url);
return NextResponse.json({ message: 'Link deactivated' });
}
return NextResponse.json({ message: 'Link still valid', daysLeft });
} catch (error) {
return NextResponse.json({ error: 'Cron check failed' }, { status: 500 });
}
}
Если вы размещаете проект на Vercel, используйте встроенные Scheduled Functions, добавив следующий блок в vercel.json:
// vercel.json
{
"crons": [
{
"path": "/api/cron/check-invite",
"schedule": "0 9 * * *"
}
]
}
Это запустит проверку каждый день в 9 утра по UTC. Если вы размещаетесь вне Vercel, настройте cron-задачу в GitHub Actions или используйте внешние планировщики.
Заключение
Мы построили простой, но мощный сервис, который решает распространенную проблему устаревания Slack-ссылок, используя современные облачные технологии. Ключевые преимущества этой архитектуры:
- Соответствие политикам Slack: Работает только с официальными ссылками-инвайтами.
- Глобальная производительность: Vercel Edge Functions и Edge Config обеспечивают мгновенный доступ по всему миру.
- Безопасность: Аутентификация через Auth0 с ограничением по домену обеспечивает высокий уровень безопасности админки.
- Надежность: Автоматический мониторинг через cron-задачи и уведомления в Slack помогают быстро реагировать на проблемы.
- Минимальное обслуживание: Сервис требует минимального ручного вмешательства, в основном для обновления Slack-ссылки раз в 30 дней.
- Адаптивность: Этот сервис легко адаптируется для других команд или проектов, требуя лишь изменения доменных ограничений в Auth0 и URL-адреса Slack Webhook.