diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbacba7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +references/ +ts.txt +*.log \ No newline at end of file diff --git a/api-tests/test.py b/api-tests/test.py new file mode 100644 index 0000000..603f8ac --- /dev/null +++ b/api-tests/test.py @@ -0,0 +1,49 @@ +import requests as req + + +class CreateItemChecks: + def createAuthor (this, result): + return 'response' in result + + def invalidType (this, result): + return 'response' in result + + +class CreateItemActions: + def createAuthor (this): + return req.post( + "http://127.0.0.1:8080/api/v/1.0/create-item?response_format=json", + json={ + "type" : "author", + "name" : "TestAuthor" + } + ).json() + + def invalidType (this): + return req.post( + "http://127.0.0.1:8080/api/v/1.0/create-item?response_format=json", + json={ + "type" : "asdasdsad", + "name" : "TestAuthor" + } + ).json() + +createItemActions = CreateItemActions() +createItemChecks = CreateItemChecks() + +def test (id, action, isDone, nextTest=lambda:None): + try: + print("""Test {id} runned..""") + result = action() + if isDone(result): + print("""Test {id} success!""") + nextTest() + else: + print("""Test {id} ERROR""") + print(result) + except Exception as exc: + print(exc) + +test_3 = lambda : test(3, createItemActions.createAuthor, createItemChecks.createAuthor) +test_2 = lambda : test(2, createItemActions.createAuthor, createItemChecks.createAuthor) +test_1 = test(1, createItemActions.invalidType, createItemChecks.invalidType, test) \ No newline at end of file diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..e262ca7 --- /dev/null +++ b/api/index.js @@ -0,0 +1,4 @@ +const router = require("express").Router(); +router.use("/v/1.0", require("./v1")); + +module.exports = router; \ No newline at end of file diff --git a/api/v1/errorClass.js b/api/v1/errorClass.js new file mode 100644 index 0000000..2c4db01 --- /dev/null +++ b/api/v1/errorClass.js @@ -0,0 +1,8 @@ +module.exports = class ApiError extends Error { + constructor(message, details = {}, ...args) { + super(message, ...args); + this.message = message; + this.details = details; + this.name = "ApiError"; + } +}; \ No newline at end of file diff --git a/api/v1/index.js b/api/v1/index.js new file mode 100644 index 0000000..76ece4a --- /dev/null +++ b/api/v1/index.js @@ -0,0 +1,58 @@ +const router = require("express").Router(); +const response = require("./response-wrapper"); +// const config = require('../../config-handler'); + +// Парсинг куки +//router.use(require('cookie-parser')()); + +// Загрузка музыки при помощи спец. метода +function muzicLoad(req, res) { + res.setHeader("Content-Type", "audio/mpeg"); + global.database.muzic.get( + (err, data) => { + data = data[0]?.data; + if (err) { + res.send(Buffer.from([])); + } else { + res.send(!data ? Buffer.from([]) : data); + } + }, + { where: { id: !Number.isInteger(+req.query.id) ? 0 : +req.query.id } }, + ); +} + +// Подгрузка с файла +router.use("/:method_name", async (req, res, next, ...etc) => { + if (req.params.method_name === "muzic") { + muzicLoad(req, res); + return; + } + + try { + const methodFunct = require(`./methods/${req.params.method_name}`); + response(methodFunct, req, res); + } catch (e) { + if (e.message.includes("Cannot find module")) { + const ApiError = require("./errorClass"); + res.status(400).sendModed( + await response( + (req, res) => { + throw new ApiError("METHOD_NOT_FOUNDED"); + }, + req, + res, + ), + ); + } else { + response( + async () => { + throw e; + }, + req, + res, + ); + } + } +}); + +module.exports = router; \ No newline at end of file diff --git a/api/v1/methods/create-item.js b/api/v1/methods/create-item.js new file mode 100644 index 0000000..15e31cb --- /dev/null +++ b/api/v1/methods/create-item.js @@ -0,0 +1,87 @@ +const ApiError = require("../errorClass"); +const config = require("../../../config-handler"); + +async function isAuthorExists(authorId) { + if (!authorId) return false; + return ( + ( + await global.database.authors + .promiseMode() + .get({ where: { id: authorId } }) + ).length !== 0 + ); +} + +async function checkSyntaxArgs(req, res) { + return !!( + // Проверка поля type. + ["author", "muzic"].indexOf(req.json?.type) !== -1 && // Проверка поля name. + typeof req.json.name === "string" && + req.json.name.length >= 2 && + (req.json.type === "muzic" + ? true + : config().authors_blacklist.filter((blacklisted) => { + return req.json.name + .toLowerCase() + .includes(blacklisted.toLowerCase()); + }).length === 0) && // Дополнительные поля для muzic + (req.json.type === "muzic" + ? !!( + // Для `muzic` + ( + // Проверка поля author_id. + (await isAuthorExists(req.json.author_id)) && // Проверка поля data. (Передаётся либо ничего, либо строка с base64) + (req.json.data === undefined + ? true + : typeof req.json.data === "string") + ) + ) + : true) + ); +} + +module.exports = async (req, res) => { + if (req.json === undefined) { + // console.log(req.headers); + throw new ApiError("METHOD_MUST_BE_POST_JSON", { + request_method: req.method, + "content-type": !req.headers["content-type"] + ? null + : req.headers["content-type"], + }); + } + if (!(await checkSyntaxArgs(req, res))) { + throw new ApiError("INVALID_OR_UNSYNTAX_PARAMS", { + request_method: req.method, + params: { + type: req.json?.type === undefined ? null : req.json.type, + name: req.json?.name === undefined ? null : req.json.name, + ...(req.json?.type === "muzic" + ? { + author_id: + req.json?.author_id === undefined ? null : req.json?.author_id, + data: req.json?.data === undefined ? null : req.json.data, + } + : {}), + }, + }); + } + if (req.json.type === "author") { + let result = await global.database.authors.promiseMode().add({ + name: req.json.name, + time: Math.round(new Date().getTime() / 1000), + }); + return result.dataValues.id; + } else { + let result = await global.database.muzic.promiseMode().add({ + name: req.json.name, + author_id: req.json.author_id, + data: + req.json.data === undefined + ? undefined + : Buffer.from(req.json.data, "base64"), + time: Math.round(new Date().getTime() / 1000), + }); + return result.dataValues.id; + } +}; \ No newline at end of file diff --git a/api/v1/methods/edit-item.js b/api/v1/methods/edit-item.js new file mode 100644 index 0000000..3790da5 --- /dev/null +++ b/api/v1/methods/edit-item.js @@ -0,0 +1,90 @@ +const ApiError = require("../errorClass"); + +async function isAuthorExists(id) { + if (!id || !Number.isInteger(id)) return false; + return ( + (await global.database.authors.promiseMode().get({ where: { id } })) + .length !== 0 + ); +} + +async function isMuzicExists(id) { + if (!id || !Number.isInteger(id)) return false; + let result = + (await global.database.muzic.promiseMode().get({ where: { id } })) + .length !== 0; + console.log("isMuzicExists", result); + return result; +} + +async function checkSyntaxArgs(req, res) { + return !!( + // Проверка поля type + ["author", "muzic"].includes(req.json.type) && // Проверка поля name + (req.json.name === undefined + ? true + : typeof req.json.name === "string" && req.json.name.length >= 2) && // Проверка id + (req.json.type === "author" + ? await isAuthorExists(req.json.id) + : await isMuzicExists(req.json.id)) && // Проверка при type=muzic + (req.json.type === "muzic" + ? // Проверка поля author_id + (req.json.author_id === undefined + ? true + : await isAuthorExists(req.json.author_id)) && // Проверка поля data. (Передаётся либо ничего, либо строка с base64) + (req.json.data === undefined ? true : typeof req.json.data === "string") + : true) + ); +} + +module.exports = async (req, res) => { + if (req.method !== "POST") { + throw new ApiError("METHOD_MUST_BE_POST_JSON", { + request_method: req.method, + }); + } + console.log(await checkSyntaxArgs(req, res)); + if (!(await checkSyntaxArgs(req, res))) { + throw new ApiError("INVALID_OR_UNSYNTAX_PARAMS", { + request_method: req.method, + params: { + id: req.json.id === undefined ? null : req.json.id, + type: req.json.type === undefined ? null : req.json.type, + name: req.json.name === undefined ? null : req.json.name, + ...(req.json.type === "muzic" + ? { + author_id: req.json.author_id, + data: req.json.data, + } + : {}), + }, + }); + } + + if (req.json.type === "author") { + await global.database.authors.promiseMode().edit( + { + name: req.json.name, + }, + { + where: { + id: req.json.id, + }, + }, + ); + } else { + await global.database.muzic.promiseMode().edit( + { + name: req.json.name, + author_id: req.json.author_id, + data: !req.json.data ? undefined : Buffer.from(req.json.data, "base64"), + }, + { + where: { + id: req.json.id, + }, + }, + ); + } + return "ok"; +}; \ No newline at end of file diff --git a/api/v1/methods/get-authors.js b/api/v1/methods/get-authors.js new file mode 100644 index 0000000..1f55801 --- /dev/null +++ b/api/v1/methods/get-authors.js @@ -0,0 +1,81 @@ +const ApiError = require("../errorClass"); + +async function checkSyntaxArgs(req, res) { + return !!( + // Проверка поля offset + (req.query.id === undefined ? true : Number.isInteger(+req.query.offset)) && // Проверка поля offset + (req.query.offset === undefined + ? true + : Number.isInteger(+req.query.offset)) && // Проверка поля count + (req.query.count === undefined + ? true + : Number.isInteger(+req.query.count)) && // Проверка поля min_date + (req.query.min_date === undefined + ? true + : Number.isInteger(+req.query.min_date)) && // Проверка поля max_date + (req.query.max_date === undefined + ? true + : Number.isInteger(+req.query.max_date)) && // Проверка поля q. (Ключевые слова для поиска) + true // (Проверки нет, query всегда строка, необязательный параметр) + ); +} + +module.exports = async (req, res) => { + if (req.method !== "GET") { + throw new ApiError("METHOD_MUST_BE_GET", { + request_method: req.method, + }); + } + if (!(await checkSyntaxArgs(req, res))) { + throw new ApiError("INVALID_OR_UNSYNTAX_PARAMS", { + request_method: req.method, + params: { + id: req.query?.id === undefined ? null : req.query.id, + q: req.query?.q === undefined ? null : req.query.q, + offset: req.query?.offset === undefined ? null : req.query.offset, + count: req.query?.count === undefined ? null : req.query.count, + min_date: req.query?.min_date === undefined ? null : req.query.min_date, + max_date: req.query?.max_date === undefined ? null : req.query.max_date, + }, + }); + } + + const offset = req.query.offset === undefined ? 0 : +req.query.offset; + const countFromNull = + req.query.count === undefined ? undefined : offset + +req.query.count; + + let result = ( + await global.database.authors.promiseMode().get( + req.query.id === undefined + ? {} + : { + id: +req.query.id, + }, + ) + ).map((i) => ({ + id: i.id, + name: i.name, + date: i.time, + })); + // Если просят выборку по времени + if (req.query.min_date || req.query.max_date) { + const minDate = req.query.min_date === undefined ? 0 : +req.query.min_date; + const maxDate = + req.query.max_date === undefined ? 1e32 : +req.query.max_date; + + result = result.filter((res) => minDate <= res.date && res.date <= maxDate); + } + + if (req.query?.q !== undefined) { + result = result.filter((i) => { + const search = req.query.q.toLowerCase(); + return i.name.toLowerCase().includes(search); + }); + } + + result = result.slice(offset, countFromNull); + return { + count: result.length, + items: result, + }; +}; \ No newline at end of file diff --git a/api/v1/methods/get-muzic.js b/api/v1/methods/get-muzic.js new file mode 100644 index 0000000..43a9998 --- /dev/null +++ b/api/v1/methods/get-muzic.js @@ -0,0 +1,142 @@ +const ApiError = require("../errorClass"); + +async function isAuthorExists(authorId) { + if (!authorId) return false; + return ( + ( + await global.database.authors + .promiseMode() + .get({ where: { id: authorId } }) + ).length !== 0 + ); +} + +async function isAuthorsExists(authorsId) { + if (!Array.isArray(authorsId)) return false; + const authors = (await global.database.authors.promiseMode().get()).map( + (i) => i.id, + ); + return authorsId.map((authorId) => authors.includes(authorId)); +} + +async function checkSyntaxArgs(req, res) { + return !!( + // Проверка поля author. + (req.query.author === undefined + ? true + : await isAuthorExists(+req.query.author)) && // Проверка поля authors. + (req.query.authors === undefined + ? true + : await isAuthorsExists(req.query.authors.split(",").map((i) => +i))) && // Проверка поля offset + (req.query.offset === undefined + ? true + : Number.isInteger(+req.query.offset)) && // Проверка поля count + (req.query.count === undefined + ? true + : Number.isInteger(+req.query.count)) && // Проверка поля min_date + (req.query.min_date === undefined + ? true + : Number.isInteger(+req.query.min_date)) && // Проверка поля max_date + (req.query.max_date === undefined + ? true + : Number.isInteger(+req.query.max_date)) && // Проверка поля q. (Ключевые слова для поиска) + true && // (Проверки нет, query всегда строка, необязательный параметр) // Проверка поля searchByAuthor (Флаг для 'q'. 0 - отключить) + true && // (Проверки нет, query всегда строка, необязательный параметр) // Проверка поля searchByName (Флаг для 'q'. 0 - отключить) + true // (Проверки нет, query всегда строка, необязательный параметр) + ); +} + +module.exports = async (req, res) => { + if (req.method !== "GET") { + throw new ApiError("METHOD_MUST_BE_GET", { + request_method: req.method, + }); + } + if (!(await checkSyntaxArgs(req, res))) { + throw new ApiError("INVALID_OR_UNSYNTAX_PARAMS", { + request_method: req.method, + params: { + author: req.query?.author === undefined ? null : req.query.author, + authors: req.query?.authors === undefined ? null : req.query.authors, + q: req.query?.q === undefined ? null : req.query.q, + offset: req.query?.offset === undefined ? null : req.query.offset, + count: req.query?.count === undefined ? null : req.query.count, + searchByAuthor: + req.query?.searchByAuthor === undefined + ? null + : req.query.searchByAuthor, + searchByName: + req.query?.searchByName === undefined ? null : req.query.searchByName, + min_date: req.query?.min_date === undefined ? null : req.query.min_date, + max_date: req.query?.max_date === undefined ? null : req.query.max_date, + }, + }); + } + + // Оптимизация: флаг searchByAuthor выставится на 0, если была запрошена выборка по музыке определённого автора + // Тем самым, мы ускоряем время ответа, поскольку мы не перебираем массив searchedAuthors с целью поиска совпадений + let searchByAuthor = + req.query.author === undefined ? req.query.searchByAuthor : "0"; + + const offset = req.query.offset === undefined ? 0 : +req.query.offset; + const countFromNull = + req.query.count === undefined ? undefined : offset + +req.query.count; + + let result = ( + await global.database.muzic.promiseMode().get( + req.query?.author === undefined + ? {} + : { + where: { + author_id: +req.query.author, + }, + }, + ) + ).map((i) => ({ + id: i.id, + name: i.name, + author_id: i.author_id, + is_data_exists: i.data !== null, + date: i.time, + })); + // Если просят выборку по authors + if (req.query.authors) { + let authors = req.query.authors.split(",").map((author) => +author); + result = result.filter((res) => authors.includes(res.author_id)); + } + // Если просят выборку по времени + if (req.query.min_date || req.query.max_date) { + const minDate = req.query.min_date === undefined ? 0 : +req.query.min_date; + const maxDate = + req.query.max_date === undefined ? 1e32 : +req.query.max_date; + + result = result.filter((res) => minDate <= res.date && res.date <= maxDate); + } + + if (req.query?.q !== undefined) { + let authors = await global.database.authors.promiseMode().get(); + let searchedAuthors = result.map((i) => { + let author_id = i.author_id; + return authors.filter((a) => a.id === author_id)[0]; + }); + result = result.filter((i) => { + const search = req.query.q.toLowerCase(); + return ( + (req.query.searchByName !== "0" && + i.name.toLowerCase().includes(search)) || + !!( + searchByAuthor !== "0" && + searchedAuthors + .filter((a) => a.id === i.author_id)[0] + ?.name.toLowerCase() + .includes(search) + ) + ); + }); + } + result = result.slice(offset, countFromNull); + return { + count: result.length, + items: result, + }; +}; \ No newline at end of file diff --git a/api/v1/methods/remove-item.js b/api/v1/methods/remove-item.js new file mode 100644 index 0000000..b0c288a --- /dev/null +++ b/api/v1/methods/remove-item.js @@ -0,0 +1,72 @@ +const ApiError = require("../errorClass"); +const config = require("../../../config-handler"); + +async function isAuthorExists(authorId) { + if (!authorId) return false; + return ( + ( + await global.database.authors + .promiseMode() + .get({ where: { id: authorId } }) + ).length !== 0 + ); +} + +async function checkSyntaxArgs(req, res) { + return !!( + // Проверка поля type. + ["author", "muzic"].indexOf(req.json?.type) !== -1 && // Проверка поля id. + (Number.isInteger(req.json.id) && req.json.type === "author" + ? await isAuthorExists(req.json.id) + : true) + ); +} + +module.exports = async (req, res) => { + if (req.json === undefined) { + // console.log(req.headers); + throw new ApiError("METHOD_MUST_BE_POST_JSON", { + request_method: req.method, + "content-type": !req.headers["content-type"] + ? null + : req.headers["content-type"], + }); + } + if (!(await checkSyntaxArgs(req, res))) { + throw new ApiError("INVALID_OR_UNSYNTAX_PARAMS", { + request_method: req.method, + params: { + type: req.json?.type === undefined ? null : req.json.type, + name: req.json?.name === undefined ? null : req.json.name, + ...(req.json?.type === "muzic" + ? { + author_id: + req.json?.author_id === undefined ? null : req.json?.author_id, + data: req.json?.data === undefined ? null : req.json.data, + } + : {}), + }, + }); + } + if (req.json.type === "author") { + // Удаляем всю музыку этого исполнителя + await global.database.muzic.promiseMode().remove({ + where: { + author_id: req.json.id, + }, + }); + // Удаляем исполнителя + await global.database.authors.promiseMode().remove({ + where: { + id: req.json.id, + }, + }); + } else { + await global.database.muzic.promiseMode().remove({ + where: { + id: req.json.id, + }, + }); + } + return "ok"; +}; \ No newline at end of file diff --git a/api/v1/response-wrapper.js b/api/v1/response-wrapper.js new file mode 100644 index 0000000..c40a381 --- /dev/null +++ b/api/v1/response-wrapper.js @@ -0,0 +1,61 @@ +const fs = require("fs"); +const config = require("../../config-handler"); + +const unknownError = (err) => { + const stackId = new Date().getTime(); + let errorLoggingFolder = config().error_logs_folder; + errorLoggingFolder = !["/", "\\"].includes(errorLoggingFolder.at(-1)) + ? errorLoggingFolder + "/" + : errorLoggingFolder; + fs.writeFileSync( + `${errorLoggingFolder}error_${stackId}.log`, + `ERROR: +Date: ${new Date()} +Name: ${err.name} +Message: ${err.message} +Stack: +${err.stack}`, + ); + + return { + error: "UNKNOWN_ERROR", + details: { + trace_id: stackId, + }, + }; +}; + +const unknownResponseFormat = "UNKNOWN_RESPONSE_FORMAT"; + +async function handlingError(funct, success, error) { + try { + success(await funct()); + } catch (e) { + // console.log('error', e); + error( + e.name === "ApiError" + ? { + error: e.message, + details: e.details, + } + : unknownError(e), + ); + } +} + +module.exports = async (method, req, res) => { + if (req.query.response_format === "json") { + handlingError( + async () => ({ response: await method(req, res) }), + async (data) => { + res.sendModed(data); + }, + async (errBody) => { + res.errorModeOn(); + res.status(400).sendModed(errBody); + }, + ); + } else { + res.status(400).sendModed(unknownResponseFormat); + } +}; \ No newline at end of file diff --git a/config-handler.js b/config-handler.js new file mode 100644 index 0000000..464d901 --- /dev/null +++ b/config-handler.js @@ -0,0 +1,8 @@ +const fs = require("fs"); +module.exports = () => { + const config = JSON.parse( + fs.readFileSync("./config.json", { encoding: "utf-8" }), + ); + // Проверить конфиг на целостность + return config; +}; diff --git a/config.json b/config.json new file mode 100644 index 0000000..0eaa747 --- /dev/null +++ b/config.json @@ -0,0 +1,24 @@ +{ + "address" : "127.0.0.1", + "port" : 8080, + "ssl" : { + "enabled" : false, + "public" : "", + "private" : "" + }, + "database" : { + "port" : 5433, + "address" : "localhost", + "username" : "postgres", + "password" : "root", + "database" : "kodex-db" + }, + "logger_mode" : true, + "logger_folder" : "./logger", + + "error_logs_folder" : "./errors", + + "authors_blacklist" : [ + "Монеточка", "Monetochka" + ] +} \ No newline at end of file diff --git a/database.js b/database.js new file mode 100644 index 0000000..448d1fb --- /dev/null +++ b/database.js @@ -0,0 +1,136 @@ +const { Sequelize, DataTypes } = require("sequelize"); + +class Table { + constructor(model) { + this.model = model; + } + + promiseMode() { + return new TablePromise(this.model); + } + + get(cb, condition = null) { + this.model + .findAll({ ...(!condition ? {} : condition), raw: true }) + .then((data) => cb(null, data)) + .catch((err) => cb(err, null)); + } + + add(item, cb) { + this.model + .create(item) + .then((i) => cb(null, i)) + .catch((err) => cb(err, null)); + } + + remove(condition, cb) { + this.model + .destroy(condition) + .then((i) => cb(null, i)) + .catch((err) => cb(err, null)); + } + + edit(data, condition, cb) { + this.model + .update(data, condition) + .then((i) => cb(null, i)) + .catch((err) => cb(err, null)); + } +} + +class TablePromise extends Table { + async get(condition) { + return await this.model.findAll({ + ...(!condition ? {} : condition), + raw: true, + }); + } + + async add(item) { + //setTimeout(() => this.model.findAll(), 0); + return await this.model.create(item); + } + + async remove(condition) { + return await this.model.destroy(condition); + } + + async edit(data, condition) { + return await this.model.update(data, condition); + } +} + +class Database { + constructor(address, port, username, password, database) { + this.sequelize = new Sequelize( + `postgres://${username}:${password}@${address}:${port}/${database}`, + ); + this.sequelize.authenticate().then( + () => { + this.authors = new Table( + this.sequelize.define( + "authors", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + }, + time: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }, + { + freezeTableName: true, + timestamps: false, + }, + ), + ); + this.muzic = new Table( + this.sequelize.define( + "muzic", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: DataTypes.TEXT, + allowNull: false, + }, + author_id: { + type: DataTypes.INTEGER, + allowNull: false, + }, + data: { + type: DataTypes.BLOB("long"), + }, + time: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }, + { + freezeTableName: true, + timestamps: false, + }, + ), + ); + console.log("Database successful connected!"); + }, + (err) => { + throw err; + }, + ); + } +} + +module.exports = { Database }; diff --git a/db-dump/install-it.sql b/db-dump/install-it.sql new file mode 100644 index 0000000..c033bb6 Binary files /dev/null and b/db-dump/install-it.sql differ diff --git a/frontend/main.html b/frontend/main.html new file mode 100644 index 0000000..144d6c4 --- /dev/null +++ b/frontend/main.html @@ -0,0 +1,41 @@ + + +
+`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `