Comment faire un module Nuxt protégé par un système de licenses délivré par Lemonsqueezy ?

By Hugo LassiègeApr 4, 20246 min read

Bloggrify c'est un project open source que j'ai développé récemment. C'est un template de blog statique, construit par-dessus Nuxt et Nuxt-Content permettant de démarrer un nouveau blog, complet en fonctionnalités, en quelques minutes, le temps d'écrire npm run dev.

Distribuer un module nuxt payant

J'ai très vite ajouté un système de thèmes qui permet facilement d'ajouter sa propre mise en page et j'ai proposé deux thèmes :

  • un thème de base, gratuit qui s'appelle Mistral
  • un thème payant qui s'appelle Epoxia.

Et là, je me suis demandé quelles étaient mes options pour distribuer ce thème. J'ai regardé :

  • themeforest : une marketplace pour vendre des morceaux de code
  • buymeacoffee, un site que j'utilise déjà et qui peut ressembler sur le fond a gumroad, utip etc...
  • un repo github privé avec mécanisme de token privé pour y avoir accès
  • npm, que je ne pense pas avoir besoin de décrire, mais qui pose une question importante, comment monétiser ?

Les deux premières solutions ont le même problème. Il faut manuellement uploader une archive, la mettre en ligne et recommencer l'opération à chaque nouvelle version. C'est ok si on doit le faire une ou deux fois, mais pas très satisfaisant sur le long terme. Surtout pour l'acheteur qui ne bénéficie d'aucune mise à jour. Par contre, la monétisation est incluse par défaut, c'est fait pour.

La 3ème option est souvent utilisé en ce moment, par exemple par shipfa.st, qui est un boilerplate NextJs pour démarrer une nouvelle application SAAS rapidement.

Si vous achetez Shipfa.st, vous recevez une invitation pour un repo privé. Le principal avantage, c'est que vous avez accès aux issues Github. Mais il faut gérer l'accès au repo automatiquement après l'achat d'une license. Et il faut gérer l'annulation d'une license (refund) donc la suppression de l'invitation. Je n'ai pas exploré cette piste, mais j'imagine que l'auteur a automatisé cette partie sinon ça peut être un enfer à gérer.

Pour ma part, je me suis plutot inspiré de la solution utilisé par Nuxt UI Pro :

  • Le package Nuxt UI Pro est utilisable gratuitement en dev.
  • L'achat du package pro permet d'obtenir une clé de license.
  • Cette clé de license permet de builder son application pour la production.

J'aime bien l'idée de permettre de jouer avec le produit en dev, et d'avoir une clé uniquement pour la production. Même si, par design, ça veut dire qu'on peut bidouiller le code pour enlever la protection.

Voyons maintenant l'implémentation.

Lemonsqueezy

J'ai choisi Lemonsqueezy sur les conseils de Sebastien Chopin, créateur de Nuxt. Je n'aime pas faire de la pub, mais je suis bien obligé d'en parler puisque c'est leur API qu'on va utiliser pour la suite.

Voyez Lemonsqueezy comme une sorte de Stripe (solution de paiement en ligne). Sauf que ça permet de gérer, en plus, les taxes, l'affiliation, le paiement récurrent, mais surtout, la création de clé de licenses.

Et ça, c'est vraiment pas mal puisque ça me permet de faire une page de vente qui délivre un numéro de license. Ensuite, il me reste à vérifier lors de son utilisation que ce numéro est valide (et actif).

Et c'est cette partie qu'on va détailler.

La validation de clé de license

Dans les grandes lignes, la validation va ressembler à ça :

  • lors du build, récupération de la license dans les variables d'environnements
  • vérification de la license avec une API
  • si la license est ok, continuer le build

Utilisation d'un hook Nuxt pour vérifier la license

Ici, on va profiter du système de Module Nuxt et on va donc ajouter un hook sur build:before qui déclenche la validation.

export default defineNuxtModule({
    setup (options, nuxt) {
        const theme = pkg.theme || { env: 'BLOGGRIFY_EPOXIA_LICENSE', link: CHECKOUT_URL_LEMONSQUEEZY }
        const key = process.env[theme.env] || ''
        
        nuxt.hook('build:before', async () => {
            await validateLicense({ key, theme, dir: nuxt.options.rootDir })
        })
    }
})

La méthode validateLicense ressemble à ceci :


export async function validateLicense (opts: { key: string, dir: string, theme: { env: string, link: string } }) {
    try {
        await ofetch('https://MY_CHECK_SERVICE/api/check', {
            headers: {
                Accept: 'application/json',
            },
            params: {
                license_key: opts.key
            }
        })
    } catch (error) {
      // manage error and break build 
    }
}

Vous noterez qu'on appelle https://MY_CHECK_SERVICE/api/check et pas directement lemonsqueezy parce que sinon il faudrait que je partage ma clé d'API lemonsqueezy à tout le monde, ce qui ne paraît pas une super idée ^^

Donc ça veut dire qu'il faut déployer cette API. Voyons cela ensemble.

Service de validation de clé d'API

Pour déployer ce service, j'ai choisi d'utiliser une application Nuxt SSR, déployé en edge, sur Netlify.

Si cette phrase est incompréhensible, je dirai pour simplifier que ça permet de déployer une application dynamique (pas uniquement des pages statiques), mais sans utiliser un serveur. L'application tourne au plus près de l'utilisateur, comme sur un CDN, mais qui en plus, sait exécuter une application Node.js

Cette application Nuxt est très simple, uniquement un fichier server/api/check.ts.

Dans ce fichier, on déclare un handler qui écoute sur /api/check et qui va valider la clé de license. Ce handler prend un paramètre license_key et va appeler l'API de Lemonsqueezy pour vérifier si la clé existe, et est valide.


const MY_PRODUCT = id_of_the_product_on_lemonsqueezy

export default defineEventHandler(async (event) => {

    const query = getQuery(event)
    const licenseKey = query?.license_key as string

    if (!licenseKey) {
        throw createError({
            statusCode: 400,
            statusMessage: 'license_key is required',
            message: 'license_key is required',
            stack: '',
        })
    }

    const lemonsqueezyApiKey = process.env.LEMONSQUEEZY_API_KEY

    if (!lemonsqueezyApiKey) {
        throw createError({
            statusCode: 400,
            statusMessage: 'LEMONSQUEEZY_API_KEY is not set',
            message: 'LEMONSQUEEZY_API_KEY is not set',
            stack: '',
        })
    }

    const response = await fetch(`https://api.lemonsqueezy.com/v1/licenses/validate?license_key=${licenseKey}`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/vnd.api+json',
            'Accept': 'application/vnd.api+json',
            'Authorization': `Bearer ${lemonsqueezyApiKey}`,
        }
    })

    if (!response.ok) {
        throw createError({
            statusCode: response.status,
            statusMessage: response.statusText,
            message: 'Invalid license key',
            stack: '',
        })
    }

    const data = await response.json()
    const isValid = data.valid
    const productId = data.meta.product_id

    if (!isValid) {
        throw createError({
            statusCode: 400,
            statusMessage: 'Invalid license key',
            message: 'Invalid license key',
            stack: '',
        })
    }

    if (productId !== MY_PRODUCT) {
        throw createError({
            statusCode: 400,
            statusMessage: 'This license is not valid for this product',
            message: 'This license is not valid for this product',
            stack: '',
        })
    }

    return {
        status: 'ok',
        message: 'The license is valid',
    }
})

Et voilà, c'est tout. On a maintenant un système de license pour notre module Nuxt.

N'hésitez pas à me poser des questions en commentaires ou me dire si vous avez des retours sur cette implémentation.


Share this:

Written by Hugo Lassiège

Software engineer, ex-freelance, ex-cofounder, ex-CTO. I love building things, sharing knowledge and helping others.

Copyright © 2024
 Eventuallycoding
  Powered by Bloggrify