diff --git a/assets/js/comment.js b/assets/js/comment.js index de245fa8d..5dbdfac66 100644 --- a/assets/js/comment.js +++ b/assets/js/comment.js @@ -3,7 +3,6 @@ */ import { $ } from './utils/dom'; -import { showOwnedComments } from './communications/comment'; import { filterNode } from './imagesclientside'; import { fetchHtml } from './utils/requests'; import { timeAgo } from './timeago'; @@ -131,9 +130,6 @@ function displayComments(container, commentsHtml) { // Filter images in the comments filterNode(container); - // Show options on own comments - showOwnedComments(); - } function loadComments(event) { @@ -175,7 +171,6 @@ function setupComments() { } else { filterNode(comments); - showOwnedComments(); } } diff --git a/assets/js/communications/comment.js b/assets/js/communications/comment.js deleted file mode 100644 index a4661c617..000000000 --- a/assets/js/communications/comment.js +++ /dev/null @@ -1,10 +0,0 @@ -import { $ } from '../utils/dom'; - -function showOwnedComments() { - const editableComments = $('.js-editable-comments'); - const editableCommentIds = editableComments && JSON.parse(editableComments.dataset.editable); - - if (editableCommentIds) editableCommentIds.forEach(id => $(`#comment_${id} .owner-options`).classList.remove('hidden')); -} - -export { showOwnedComments }; diff --git a/assets/js/communications/post.js b/assets/js/communications/post.js deleted file mode 100644 index d21722204..000000000 --- a/assets/js/communications/post.js +++ /dev/null @@ -1,10 +0,0 @@ -import { $ } from '../utils/dom'; - -function showOwnedPosts() { - const editablePost = $('.js-editable-posts'); - const editablePostIds = editablePost && JSON.parse(editablePost.dataset.editable); - - if (editablePostIds) editablePostIds.forEach(id => $(`#post_${id} .owner-options`).classList.remove('hidden')); -} - -export { showOwnedPosts }; diff --git a/assets/js/duplicate_reports.js b/assets/js/duplicate_reports.js deleted file mode 100644 index a6794ec49..000000000 --- a/assets/js/duplicate_reports.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Interactive behavior for duplicate reports. - */ - -import { $, $$ } from './utils/dom'; - -function setupDupeReports() { - const [ onion, slider ] = $$('.onion-skin__image, .onion-skin__slider'); - const swipe = $('.swipe__image'); - - if (swipe) setupSwipe(swipe); - if (onion) setupOnionSkin(onion, slider); -} - -function setupSwipe(swipe) { - const [ clip, divider ] = $$('#clip rect, #divider', swipe); - const { width } = swipe.viewBox.baseVal; - - function moveDivider({ clientX }) { - // Move center to cursor - const rect = swipe.getBoundingClientRect(); - const newX = (clientX - rect.left) * (width / rect.width); - - divider.setAttribute('x', newX); - clip.setAttribute('width', newX); - } - - swipe.addEventListener('mousemove', moveDivider); -} - -function setupOnionSkin(onion, slider) { - const target = $('#target', onion); - - function setOpacity() { - target.setAttribute('opacity', slider.value); - } - - setOpacity(); - slider.addEventListener('input', setOpacity); -} - -export { setupDupeReports }; diff --git a/assets/js/duplicate_reports.ts b/assets/js/duplicate_reports.ts new file mode 100644 index 000000000..55cdfeb16 --- /dev/null +++ b/assets/js/duplicate_reports.ts @@ -0,0 +1,42 @@ +/** + * Interactive behavior for duplicate reports. + */ + +import { assertNotNull } from './utils/assert'; +import { $, $$ } from './utils/dom'; + +export function setupDupeReports() { + const onion = $('.onion-skin__image'); + const slider = $('.onion-skin__slider'); + const swipe = $('.swipe__image'); + + if (swipe) setupSwipe(swipe); + if (onion && slider) setupOnionSkin(onion, slider); +} + +function setupSwipe(swipe: SVGSVGElement) { + const [ clip, divider ] = $$('#clip rect, #divider', swipe); + const { width } = swipe.viewBox.baseVal; + + function moveDivider({ clientX }: MouseEvent) { + // Move center to cursor + const rect = swipe.getBoundingClientRect(); + const newX = (clientX - rect.left) * (width / rect.width); + + divider.setAttribute('x', newX.toString()); + clip.setAttribute('width', newX.toString()); + } + + swipe.addEventListener('mousemove', moveDivider); +} + +function setupOnionSkin(onion: SVGSVGElement, slider: HTMLInputElement) { + const target = assertNotNull($('#target', onion)); + + function setOpacity() { + target.setAttribute('opacity', slider.value); + } + + setOpacity(); + slider.addEventListener('input', setOpacity); +} diff --git a/assets/js/shortcuts.js b/assets/js/shortcuts.js deleted file mode 100644 index 67d1acbd2..000000000 --- a/assets/js/shortcuts.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Keyboard shortcuts - */ - -import { $ } from './utils/dom'; - -function getHover() { - const thumbBoxHover = $('.media-box:hover'); - if (thumbBoxHover) return thumbBoxHover.dataset.imageId; -} - -function openFullView() { - const imageHover = $('[data-uris]:hover'); - if (!imageHover) return; - - window.location = JSON.parse(imageHover.dataset.uris).full; -} - -function openFullViewNewTab() { - const imageHover = $('[data-uris]:hover'); - if (!imageHover) return; - - window.open(JSON.parse(imageHover.dataset.uris).full); -} - -function click(selector) { - const el = $(selector); - if (el) el.click(); -} - -function isOK(event) { - return !event.altKey && !event.ctrlKey && !event.metaKey && - document.activeElement.tagName !== 'INPUT' && - document.activeElement.tagName !== 'TEXTAREA'; -} - -const keyCodes = { - 74() { click('.js-prev'); }, // J - go to previous image - 73() { click('.js-up'); }, // I - go to index page - 75() { click('.js-next'); }, // K - go to next image - 82() { click('.js-rand'); }, // R - go to random image - 83() { click('.js-source-link'); }, // S - go to image source - 76() { click('.js-tag-sauce-toggle'); }, // L - edit tags - 79() { openFullView(); }, // O - open original - 86() { openFullViewNewTab(); }, // V - open original in a new tab - 70() { // F - favourite image - getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`) - : click('.block__header a.interaction--fave'); - }, - 85() { // U - upvote image - getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`) - : click('.block__header a.interaction--upvote'); - }, -}; - -function listenForKeys() { - document.addEventListener('keydown', event => { - if (isOK(event) && keyCodes[event.keyCode]) { keyCodes[event.keyCode](); event.preventDefault(); } - }); -} - -export { listenForKeys }; diff --git a/assets/js/shortcuts.ts b/assets/js/shortcuts.ts new file mode 100644 index 000000000..a3254c166 --- /dev/null +++ b/assets/js/shortcuts.ts @@ -0,0 +1,74 @@ +/** + * Keyboard shortcuts + */ + +import { $ } from './utils/dom'; + +interface ShortcutKeycodes { + [key: string]: () => void +} + +function getHover(): string | null { + const thumbBoxHover = $('.media-box:hover'); + + return thumbBoxHover && (thumbBoxHover.dataset.imageId || null); +} + +function openFullView() { + const imageHover = $('[data-uris]:hover'); + + if (!imageHover || !imageHover.dataset.uris) return; + + window.location = JSON.parse(imageHover.dataset.uris).full; +} + +function openFullViewNewTab() { + const imageHover = $('[data-uris]:hover'); + + if (!imageHover || !imageHover.dataset.uris) return; + + window.open(JSON.parse(imageHover.dataset.uris).full); +} + +function click(selector: string) { + const el = $(selector); + + if (el) { + el.click(); + } +} + +function isOK(event: KeyboardEvent): boolean { + return !event.altKey && !event.ctrlKey && !event.metaKey && + document.activeElement !== null && + document.activeElement.tagName !== 'INPUT' && + document.activeElement.tagName !== 'TEXTAREA'; +} + +const keyCodes: ShortcutKeycodes = { + KeyJ() { click('.js-prev'); }, // J - go to previous image + KeyI() { click('.js-up'); }, // I - go to index page + KeyK() { click('.js-next'); }, // K - go to next image + KeyR() { click('.js-rand'); }, // R - go to random image + KeyS() { click('.js-source-link'); }, // S - go to image source + KeyL() { click('.js-tag-sauce-toggle'); }, // L - edit tags + KeyO() { openFullView(); }, // O - open original + KeyV() { openFullViewNewTab(); }, // V - open original in a new tab + KeyF() { // F - favourite image + getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`) + : click('.block__header a.interaction--fave'); + }, + KeyU() { // U - upvote image + getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`) + : click('.block__header a.interaction--upvote'); + }, +}; + +export function listenForKeys() { + document.addEventListener('keydown', (event: KeyboardEvent) => { + if (isOK(event) && keyCodes[event.code]) { + keyCodes[event.code](); + event.preventDefault(); + } + }); +} diff --git a/assets/js/staffhider.js b/assets/js/staffhider.js deleted file mode 100644 index 68b64447f..000000000 --- a/assets/js/staffhider.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * StaffHider - * - * Hide staff elements if enabled in the settings. - */ - -import { $$ } from './utils/dom'; - -function hideStaffTools() { - if (window.booru.hideStaffTools === 'true') { - $$('.js-staff-action').forEach(el => { - el.classList.add('hidden'); - }); - } -} - -export { hideStaffTools }; diff --git a/assets/js/staffhider.ts b/assets/js/staffhider.ts new file mode 100644 index 000000000..86741d785 --- /dev/null +++ b/assets/js/staffhider.ts @@ -0,0 +1,13 @@ +/** + * StaffHider + * + * Hide staff elements if enabled in the settings. + */ + +import { $$, hideEl } from './utils/dom'; + +export function hideStaffTools() { + if (window.booru.hideStaffTools === 'true') { + $$('.js-staff-action').forEach(el => hideEl(el)); + } +} diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js index ae6eaa0c1..b05cf12b1 100644 --- a/assets/js/when-ready.js +++ b/assets/js/when-ready.js @@ -5,9 +5,6 @@ import { whenReady, $ } from './utils/dom'; -import { showOwnedComments } from './communications/comment'; -import { showOwnedPosts } from './communications/post'; - import { listenAutocomplete } from './autocomplete'; import { loadBooruData } from './booru'; import { registerEvents } from './boorujs'; @@ -40,8 +37,6 @@ import { imageSourcesCreator } from './sources'; whenReady(() => { - showOwnedComments(); - showOwnedPosts(); loadBooruData(); listenAutocomplete(); registerEvents(); diff --git a/assets/test/vitest-setup.ts b/assets/test/vitest-setup.ts index d889b27f4..a1f3c6265 100644 --- a/assets/test/vitest-setup.ts +++ b/assets/test/vitest-setup.ts @@ -10,6 +10,7 @@ window.booru = { csrfToken: 'mockCsrfToken', hiddenTag: '/mock-tagblocked.svg', hiddenTagList: [], + hideStaffTools: 'true', ignoredTagList: [], imagesWithDownvotingDisabled: [], spoilerType: 'off', diff --git a/assets/types/booru-object.d.ts b/assets/types/booru-object.d.ts index 22d1aa08a..2a5949c03 100644 --- a/assets/types/booru-object.d.ts +++ b/assets/types/booru-object.d.ts @@ -65,6 +65,10 @@ interface BooruObject { spoileredFilter: AstMatcher; tagsVersion: number; interactions: Interaction[]; + /** + * Indicates whether sensitive staff-only info should be hidden or not. + */ + hideStaffTools: string; } declare global { diff --git a/lib/philomena_query/ip_mask.ex b/lib/philomena_query/ip_mask.ex new file mode 100644 index 000000000..6bf7c1923 --- /dev/null +++ b/lib/philomena_query/ip_mask.ex @@ -0,0 +1,109 @@ +defmodule PhilomenaQuery.IpMask do + @moduledoc """ + Postgres IP masks. + """ + + @doc """ + Parse a netmask from a string parameter, producing an `m:Postgrex.INET` type suitable for use in + a containment (<<=, <<, >>, >>=) query. Ignores invalid strings and passes the IP through on + error. [Postgres documentation](https://www.postgresql.org/docs/current/functions-net.html) + has more information on `inet` operations. + + > #### Info {: .info} + > + > Netmasks lower than /8 are clamped to a minimum of /8. Such low masks are unlikely to be + > useful and this avoids producing very expensive masks to evaluate. + + ## Examples + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "12"}) + %Postgrex.INET{address: {192, 160, 0, 0}, netmask: 12} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "4"}) + %Postgrex.INET{address: {192, 0, 0, 0}, netmask: 8} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "64"}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{"mask" => "e"}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32}, %{}) + %Postgrex.INET{address: {192, 168, 1, 1}, netmask: 32} + + iex> parse_mask(%Postgrex.INET{ + ...> address: {0x2001, 0xab0, 0x33a8, 0xd6e2, 0x10e9, 0xac1b, 0x9b0f, 0x67bc}, + ...> netmask: 128 + ...> }, %{"mask" => "64"}) + %Postgrex.INET{address: {8193, 2736, 13224, 55010, 0, 0, 0, 0}, netmask: 64} + + """ + @spec parse_mask(Postgrex.INET.t(), map()) :: Postgrex.INET.t() + def parse_mask(ip, params) + + def parse_mask(ip, %{"mask" => mask}) when is_binary(mask) do + case Integer.parse(mask) do + {mask, _rest} -> + mask = clamp_mask(ip.address, mask) + address = apply_mask(ip.address, mask) + + %Postgrex.INET{address: address, netmask: mask} + + _ -> + ip + end + end + + def parse_mask(ip, _params), do: ip + + defp clamp(n, min, _max) when n < min, do: min + defp clamp(n, _min, max) when n > max, do: max + defp clamp(n, _min, _max), do: n + + defp clamp_mask(ip, mask) do + # Clamp mask length: + # - low end 8 (too taxing to evaluate) + # - high end address_bits (limit of address) + case tuple_size(ip) do + 4 -> + clamp(mask, 8, 32) + + 8 -> + clamp(mask, 8, 128) + end + end + + defp unit_length(ip) when tuple_size(ip) == 4, do: 8 + defp unit_length(ip) when tuple_size(ip) == 8, do: 16 + + defp apply_mask(ip, mask) when is_tuple(ip) do + # Determine whether elements are octets or hexadectets + length = unit_length(ip) + + # 1. Convert tuple to list of octets/hexadectets + # 2. Convert list to bitstring + # 3. Perform truncation operation on bitstring + # 4. Convert bitstring back to list of octets/hexadectets + # 5. Convert list to tuple + + ip + |> Tuple.to_list() + |> list_to_bits(length) + |> apply_mask(mask) + |> bits_to_list(length) + |> List.to_tuple() + end + + defp apply_mask(ip, mask) when is_binary(ip) do + # Truncate bit size of ip to mask length and zero-fill the remainder + <> + end + + defp list_to_bits(list, unit_length) do + for u <- list, into: <<>>, do: <> + end + + defp bits_to_list(bits, unit_length) do + for <>, do: u + end +end diff --git a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex index f5bf868c2..d82359e28 100644 --- a/lib/philomena_web/controllers/ip_profile/source_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/source_change_controller.ex @@ -1,25 +1,27 @@ defmodule PhilomenaWeb.IpProfile.SourceChangeController do use PhilomenaWeb, :controller + alias PhilomenaQuery.IpMask alias Philomena.SourceChanges.SourceChange alias Philomena.Repo import Ecto.Query plug :verify_authorized - def index(conn, %{"ip_profile_id" => ip}) do + def index(conn, %{"ip_profile_id" => ip} = params) do {:ok, ip} = EctoNetwork.INET.cast(ip) + range = IpMask.parse_mask(ip, params) source_changes = SourceChange - |> where(ip: ^ip) + |> where(fragment("? >>= ip", ^range)) |> order_by(desc: :id) |> preload([:user, image: [:user, :sources, tags: :aliases]]) |> Repo.paginate(conn.assigns.scrivener) render(conn, "index.html", title: "Source Changes for IP `#{ip}'", - ip: ip, + ip: range, source_changes: source_changes ) end diff --git a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex index b9779913a..bdfebc29f 100644 --- a/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex +++ b/lib/philomena_web/controllers/ip_profile/tag_change_controller.ex @@ -1,6 +1,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do use PhilomenaWeb, :controller + alias PhilomenaQuery.IpMask alias Philomena.TagChanges.TagChange alias Philomena.Repo import Ecto.Query @@ -9,10 +10,11 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do def index(conn, %{"ip_profile_id" => ip} = params) do {:ok, ip} = EctoNetwork.INET.cast(ip) + range = IpMask.parse_mask(ip, params) tag_changes = TagChange - |> where(ip: ^ip) + |> where(fragment("? >>= ip", ^range)) |> added_filter(params) |> preload([:tag, :user, image: [:user, :sources, tags: :aliases]]) |> order_by(desc: :id) @@ -20,7 +22,7 @@ defmodule PhilomenaWeb.IpProfile.TagChangeController do render(conn, "index.html", title: "Tag Changes for IP `#{ip}'", - ip: ip, + ip: range, tag_changes: tag_changes ) end diff --git a/lib/philomena_web/templates/ip_profile/show.html.slime b/lib/philomena_web/templates/ip_profile/show.html.slime index eb3ac25c5..da8acd3d3 100644 --- a/lib/philomena_web/templates/ip_profile/show.html.slime +++ b/lib/philomena_web/templates/ip_profile/show.html.slime @@ -11,8 +11,17 @@ ul h2 Administration Options ul - li = link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" - li = link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" + li + => link "View tag changes", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes" + = if ipv6?(@ip) do + ' … + = link "(/64)", to: ~p"/ip_profiles/#{to_string(@ip)}/tag_changes?mask=64" + li + => link "View source URL history", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes" + = if ipv6?(@ip) do + ' … + = link "(/64)", to: ~p"/ip_profiles/#{to_string(@ip)}/source_changes?mask=64" + li = link "View reports this IP has made", to: ~p"/admin/reports?#{[rq: "ip:#{@ip}"]}" li = link "View IP ban history", to: ~p"/admin/subnet_bans?#{[ip: to_string(@ip)]}" li = link "Ban this sucker", to: ~p"/admin/subnet_bans/new?#{[specification: to_string(@ip)]}" diff --git a/lib/philomena_web/views/ip_profile_view.ex b/lib/philomena_web/views/ip_profile_view.ex index 9aef6c291..a9f99f208 100644 --- a/lib/philomena_web/views/ip_profile_view.ex +++ b/lib/philomena_web/views/ip_profile_view.ex @@ -1,3 +1,8 @@ defmodule PhilomenaWeb.IpProfileView do use PhilomenaWeb, :view + + @spec ipv6?(Postgrex.INET.t()) :: boolean() + def ipv6?(ip) do + tuple_size(ip.address) == 8 + end end