Add guides
|
@ -33,3 +33,5 @@ source: source
|
||||||
collections:
|
collections:
|
||||||
projects:
|
projects:
|
||||||
output: true
|
output: true
|
||||||
|
guides:
|
||||||
|
output: true
|
||||||
|
|
|
@ -7,5 +7,9 @@ header:
|
||||||
link: /about
|
link: /about
|
||||||
- name: Projects
|
- name: Projects
|
||||||
link: /projects
|
link: /projects
|
||||||
|
directory: true
|
||||||
|
- name: Guides
|
||||||
|
link: /guides
|
||||||
|
directory: true
|
||||||
- name: Contact
|
- name: Contact
|
||||||
link: /contact
|
link: /contact
|
|
@ -17,10 +17,9 @@ links:
|
||||||
style:
|
style:
|
||||||
fa-classes: [ fa-brands, fa-linkedin ]
|
fa-classes: [ fa-brands, fa-linkedin ]
|
||||||
color-primary: 0077B5
|
color-primary: 0077B5
|
||||||
- name: LeetCode
|
# No clue what to put here to I'm linking to my doom page lmao
|
||||||
url: https://leetcode.com/owenryan/
|
- name: DOOM
|
||||||
|
url: https://owenryan.us/doom
|
||||||
style:
|
style:
|
||||||
# Leetcode's logo is not in fontawesome so just using this generic one
|
fa-classes: [ fa-solid, fa-spaghetti-monster-flying ]
|
||||||
fa-classes: [ fa-solid, fa-laptop-code ]
|
color-primary: A72626
|
||||||
# Leetcode does not have a press kit, and does not publish their brand colors. Hopefully this is close enough.
|
|
||||||
color-primary: FFA116
|
|
||||||
|
|
306
source/_guides/pfsense.md
Normal file
|
@ -0,0 +1,306 @@
|
||||||
|
---
|
||||||
|
layout: guide
|
||||||
|
title: "pfSense Guide"
|
||||||
|
description: How to convert an old desktop PC into a router/firewall combo
|
||||||
|
carousels:
|
||||||
|
installing:
|
||||||
|
title: Navigating the Install Wizard
|
||||||
|
steps:
|
||||||
|
- text: Wait for the installer to boot
|
||||||
|
image: /assets/images/guides/pfsense/setup-boot.png
|
||||||
|
- text: Accept the copyright agreement
|
||||||
|
image: /assets/images/guides/pfsense/setup-copyright.png
|
||||||
|
- text: Select `Install` on the welcome screen
|
||||||
|
image: /assets/images/guides/pfsense/setup-welcome.png
|
||||||
|
- text: For partitioning, select `Auto (ZFS)`
|
||||||
|
image: /assets/images/guides/pfsense/setup-partitioning.png
|
||||||
|
- text: Wait for the installer to probe for devices
|
||||||
|
image: /assets/images/guides/pfsense/setup-probe.png
|
||||||
|
- text: On the ZFS Configuration menu, select `Pool Type/Disks` and press enter
|
||||||
|
image: /assets/images/guides/pfsense/setup-zfs-menu-pool.png
|
||||||
|
- text: When asked for device type, choose `Striped`. Then press enter
|
||||||
|
image: /assets/images/guides/pfsense/setup-zfs-type.png
|
||||||
|
- text: Select your hard drive and press space to add it to the pool, then press enter
|
||||||
|
image: /assets/images/guides/pfsense/setup-zfs-drives.png
|
||||||
|
- text: You should now be back on the main ZFS configuration window. Select `Install` and press enter
|
||||||
|
image: /assets/images/guides/pfsense/setup-zfs-menu-install.png
|
||||||
|
- text: Confirm you would like to wipe the drive
|
||||||
|
image: /assets/images/guides/pfsense/setup-confirm.png
|
||||||
|
- text: Wait for it to install the Operating System
|
||||||
|
image: /assets/images/guides/pfsense/setup-installing.png
|
||||||
|
- text: When the installation completes, select `Restart`
|
||||||
|
image: /assets/images/guides/pfsense/setup-complete.png
|
||||||
|
- text: After the screen goes black, remove your USB drive
|
||||||
|
image: /assets/images/guides/pfsense/black.png
|
||||||
|
---
|
||||||
|
|
||||||
|
**NOTE: This article is still under development. Some sections are incomplete or need clarification**
|
||||||
|
|
||||||
|
# Goals / Who this is for
|
||||||
|
|
||||||
|
This guide is targeted at people who already understand the basics of computer networking (IP Addresses, DHCP, etc.),
|
||||||
|
and want to dive further down the discovery rabbithole, while also building a functioning firewall that can be
|
||||||
|
configured to help network security.
|
||||||
|
|
||||||
|
**Note:** pfSense requires a bit of time messing around in the web UI until you get the hang of it, so get ready to
|
||||||
|
spend
|
||||||
|
some time to get acquainted to the interface.
|
||||||
|
|
||||||
|
# Hardware
|
||||||
|
|
||||||
|
Pretty much any old desktop computer from the last ~15 years should be more than enough as long as it has:
|
||||||
|
|
||||||
|
- An x86-64 CPU (As long it's a consumer PC or server made in the last 15 years it should be fine)
|
||||||
|
- Two RJ45 Ethernet ports
|
||||||
|
- Most computers only have one, but one can be added through USB or PCIe
|
||||||
|
- I would recommend the [TP-Link TG-3468](https://www.tp-link.com/us/home-networking/pci-adapter/tg-3468/) as a
|
||||||
|
budget-friendly PCIe expansion card.
|
||||||
|
- At least 2GB of System Memory (RAM)
|
||||||
|
- pfSense requires 1GB, but more is needed when there are many connected devices.
|
||||||
|
- A USB drive to hold the installer (Must have > 1GB capacity)
|
||||||
|
|
||||||
|
pfSense is based on the FreeBSD operating system. A full list of supported hardware can be found
|
||||||
|
[on the FreeBSD website](https://www.freebsd.org/releases/13.0R/hardware/).
|
||||||
|
|
||||||
|
# Example network topology
|
||||||
|
|
||||||
|
In this guide, we will assume that this firewall is between your ISP-provided modem and your wireless router. If your
|
||||||
|
ISP only provided a modem/router combo box, contact support to ask about using your own router.
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/topology.png" alt="TODO">
|
||||||
|
|
||||||
|
# pfSense CE vs pfSense Plus
|
||||||
|
|
||||||
|
The pfSense branding applies to two different operating systems, pfSense CE (Community Edition) and pfSense Plus.
|
||||||
|
pfSense CE is Free and Open Source, meaning that the source code is freely available to view, modify, and redistribute.
|
||||||
|
pfSense Plus is a closed-source version maintained by Netgate.
|
||||||
|
|
||||||
|
For more information on the differences between pfSense CE and pfSense Plus, view the
|
||||||
|
[official FAQ](https://www.netgate.com/support/frequently-asked-questions-pfsense-plus)
|
||||||
|
|
||||||
|
**Note:** pfSense Plus used to be free for non-commercial use, but Netgate has removed that subscription tier. The
|
||||||
|
cheapest subscription plan is $129 per year.
|
||||||
|
|
||||||
|
# Installing the OS
|
||||||
|
|
||||||
|
Note: This guide assumes that your firewall has a video output. If you are using serial, please follow the
|
||||||
|
[official documentation](https://docs.netgate.com/pfsense/en/latest/install/download-installer-image.html)
|
||||||
|
|
||||||
|
## Downloading the OS
|
||||||
|
|
||||||
|
The pfSense CE download image can be obtained from [the pfSense website](https://www.pfsense.org/download/).
|
||||||
|
|
||||||
|
Architecture should be set to **AMD64**, and installer can be set to USB Memstick or DVD Image. Both can be flashed onto
|
||||||
|
a USB stick. [Here is a full comparison](https://en.wikipedia.org/wiki/IMG_(file_format)#Comparison_to_ISO_images)
|
||||||
|
|
||||||
|
If you have chosen to use pfSense plus, you can update from pfSense CE to pfSense Plus after installation.
|
||||||
|
|
||||||
|
## Flashing the Image onto a USB Stick
|
||||||
|
|
||||||
|
Once the file has downloaded, open the usb-flasher of your choice (I recommend
|
||||||
|
[Balena Etcher](https://etcher.balena.io/)), and flash the file.
|
||||||
|
|
||||||
|
**Note: This will wipe the contents of the USB stick, so make sure there's nothing important on it.**
|
||||||
|
|
||||||
|
# Booting into the installer
|
||||||
|
|
||||||
|
1. Insert the USB device into a USB port on the back of the motherboard (Front of the case should be fine but don't use
|
||||||
|
a USB hub)
|
||||||
|
2. Shut down the device
|
||||||
|
3. Start the device
|
||||||
|
4. Open the boot selection screen
|
||||||
|
- This is different for every computer, but normally holding down `DELETE` works.
|
||||||
|
- You can also try `F2` or `F12`
|
||||||
|
- If those don't work, consult your motherboard or computer's manual.
|
||||||
|
5. Select to boot from the USB drive
|
||||||
|
6. You should now see a pfSense boot screen. Press `Enter` or wait a few seconds for the installer to start booting
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/setup-bootloader.png" alt="TODO">
|
||||||
|
|
||||||
|
# Installing
|
||||||
|
|
||||||
|
{% include guide-carousel.html id="installing" %}
|
||||||
|
|
||||||
|
# Configuring pfSense
|
||||||
|
|
||||||
|
## Getting the router's IP address
|
||||||
|
|
||||||
|
After the system boot process completes, you should see a screen that looks like this.
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/cli-menu.png" alt="TODO">
|
||||||
|
|
||||||
|
The router displays its WAN address (public IP address), and LAN address (local IP address)
|
||||||
|
|
||||||
|
in my case, the local IP address is `192.168.1.1`
|
||||||
|
|
||||||
|
## Accessing the Web Dashboard
|
||||||
|
|
||||||
|
Going to `http://ROUTER_LOCAL_IP_ADDRESS` in your browser should bring you to a login screen
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/pfsense-login.png" alt="TODO">
|
||||||
|
|
||||||
|
The default username is `admin`, and the default password is `pfsense`
|
||||||
|
|
||||||
|
You should now be prompted with a setup wizard.
|
||||||
|
|
||||||
|
Skip step 1 since it's just an ad for paid support
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/wizard-welcome.png" alt="TODO">
|
||||||
|
|
||||||
|
On step 2:
|
||||||
|
|
||||||
|
- Set the hostname of the firewall (or leave it as pfSense)
|
||||||
|
- Give it a subdomain if you have one (TODO)
|
||||||
|
- Set DNS servers (1.1.1.1 and 1.0.0.1 are maintained by Cloudflare)
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/wizard-general.png" alt="TODO">
|
||||||
|
|
||||||
|
On step 3:
|
||||||
|
|
||||||
|
- Set the timezone
|
||||||
|
- Change the network timeserver if you are into that
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/wizard-timezone.png" alt="TODO">
|
||||||
|
|
||||||
|
On step 4, this is where the magic happens.
|
||||||
|
If this firewall is between your ISP provided modem, there is a good chance using DHCP will work completely fine, but it
|
||||||
|
depends on your specific ISP.
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/wizard-wan.png" alt="TODO">
|
||||||
|
|
||||||
|
On step 5, you can set your local IP Address range. The default is `192.168.1.1/16` but if you need more addresses, you
|
||||||
|
can use `10.0.0.0/8`
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/wizard-lan.png" alt="TODO">
|
||||||
|
|
||||||
|
On step 6, set the password for the webGUI. Make sure to use a secure password
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/wizard-password.png" alt="TODO">
|
||||||
|
|
||||||
|
On step 7, and 8: reload the system
|
||||||
|
|
||||||
|
The firewall will now restart, if you changed the IP address, you need to change the address in your browser
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Note: This list is not comprehensive.
|
||||||
|
|
||||||
|
- Enable dark mode
|
||||||
|
- System > General Setup > webConfigurator section > Theme
|
||||||
|
- Set to pfSense-Dark
|
||||||
|
- Remove "Netgate Services and Support tab from dashboard"
|
||||||
|
- Press the X in the top-right corner of the window
|
||||||
|
- Add the traffic graph to the dashboard
|
||||||
|
- Press the plus in the top right corner of the dashboard and select "Traffic Graph"
|
||||||
|
- Upgrade to PFSense Plus if you are willing to pay at least $129/year
|
||||||
|
- Purchase a license from
|
||||||
|
the [PFSense Plus store page](https://shop.netgate.com/products/pfsense-software-subscription)
|
||||||
|
- Insert the code provided in System > Register
|
||||||
|
|
||||||
|
# Setting up IPv6
|
||||||
|
|
||||||
|
## What is IPv6
|
||||||
|
|
||||||
|
IPv6 is a newer implementation of the Internet Protocol than the classic IPv4 and was mainly created to add more IP
|
||||||
|
addresses. IPv4 supports a maximum of 2^32 (`4,294,967,296`) addresses, while IPv6 has a maximum of 2^128
|
||||||
|
(340,282,366,920,938,463,463,374,607,431,768,211,456) addresses.
|
||||||
|
|
||||||
|
## Do I need one?
|
||||||
|
|
||||||
|
IPv4 addresses are now a commodity, and thus some people (including me) have moved to hosting exclusively* using IPv6.
|
||||||
|
|
||||||
|
*My website is still accessible over the IPv4 internet through a network translation service, but I will probably pull
|
||||||
|
the plug when IPv6 becomes more widely adopted.
|
||||||
|
|
||||||
|
Your ISP might have already rolled out IPv6 to your area. You can check your IPv6 status using
|
||||||
|
[this website](https://test-ipv6.com/).
|
||||||
|
|
||||||
|
## Obtaining an IPv6 address block with TunnelBroker
|
||||||
|
|
||||||
|
Netgate provides an [official tutorial](https://docs.netgate.com/pfsense/en/latest/recipes/ipv6-tunnel-broker.html) on
|
||||||
|
how to get a block of IPv6 addresses with TunnelBroker and how to configure pfSense to use them.
|
||||||
|
|
||||||
|
# Installing and configuring Snort
|
||||||
|
|
||||||
|
Snort is a rule-based firewall software that blocks incoming and outgoing network packets based on user-configured
|
||||||
|
rules.
|
||||||
|
|
||||||
|
Another issue caused by the IPv4 address space being completely full is that people run bots that ping random addresses
|
||||||
|
for both research and malicious reasons.
|
||||||
|
|
||||||
|
## Installing the Snort package
|
||||||
|
|
||||||
|
1. Open the Package Manager's Available Package view (System > Package Manager > Available Packages)
|
||||||
|
<img src="/assets/images/guides/pfsense/pfsense-packages.png" alt="TODO">
|
||||||
|
2. Install the Snort Package
|
||||||
|
<img src="/assets/images/guides/pfsense/pfsense-package-confirm.png" alt="TODO">
|
||||||
|
3. Wait for the installation to complete
|
||||||
|
<img src="/assets/images/guides/pfsense/pfsense-package-install.png" alt="TODO">
|
||||||
|
|
||||||
|
## Configuring Snort
|
||||||
|
|
||||||
|
1. First, go to the snort configuration menu (Services > Snort), and click on the `Interfaces` tab if it does not send
|
||||||
|
you to that page.
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-interfaces-empty.png" alt="TODO">
|
||||||
|
2. Add the WAN interface to be filtered by pressing the `Add` button in the bottom right
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-interfaces-empty.png" alt="TODO">
|
||||||
|
3. Select the WAN interface
|
||||||
|
4. By default, snort will only save alerts of offences. For extra security, you can enable the `Block Offenders` option,
|
||||||
|
but ensure that it will only block the source address, and be aware that on rare occasion you might accidentally
|
||||||
|
block yourself.
|
||||||
|
5. Press save at the bottom of the page to save interface settings
|
||||||
|
6. Going back to the `Snort Interfaces` tab should show the WAN interface
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-interfaces-off.png" alt="TODO">
|
||||||
|
7. Enable scanning by pressing the Play button and waiting for it to start
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-interfaces-on.png" alt="TODO">
|
||||||
|
|
||||||
|
### Adding rules
|
||||||
|
|
||||||
|
Snort blocks connections based on rulesets that can be obtained from multiple sources:
|
||||||
|
|
||||||
|
- Snort VRT (Requires account, has free and paid tiers)
|
||||||
|
- Snort GPL (Free without account)
|
||||||
|
- ET Open (Free without account)
|
||||||
|
- ET Pro (Targeted at large companies; does not publicly list prices)
|
||||||
|
|
||||||
|
To Add rules:
|
||||||
|
|
||||||
|
1. Go to `Global Settings`
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-globalconfig1.png" alt="TODO">
|
||||||
|
2. Enable the rule sources you desire
|
||||||
|
3. Set the update interval to 1 Day
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-globalconfig2.png" alt="TODO">
|
||||||
|
4. Press `Save`
|
||||||
|
|
||||||
|
### Downloading Rules
|
||||||
|
|
||||||
|
Go to Updates and press the `Update Rules` button. This will fetch all rules you enabled in the previous step
|
||||||
|
|
||||||
|
### Configuring rules
|
||||||
|
|
||||||
|
1. Go to the `Snort Interfaces` tab
|
||||||
|
2. Click on the pencil icon associated with the WAN interface
|
||||||
|
3. Go to the `WAN Categories` tab
|
||||||
|
4. Choose the rule sets you would like to use
|
||||||
|
|
||||||
|
You can either cherry-pick which rules you would like to apply, or you can press `Select All` at the top, and manually
|
||||||
|
whitelist proper traffic that gets blocked.
|
||||||
|
|
||||||
|
### Viewing alerts in real time
|
||||||
|
|
||||||
|
In the `Alerts` tab, you can view IP addresses that have been blocked by the selected rules
|
||||||
|
|
||||||
|
<img src="/assets/images/guides/pfsense/snort-alerts.png" alt="TODO">
|
||||||
|
|
||||||
|
This image is from an actual pfSense deployment, so many fields have been blurred
|
||||||
|
|
||||||
|
# Conclusion
|
||||||
|
|
||||||
|
Congratulations! You now have an open-source* firewall protecting your network! Here are some links to other guides on
|
||||||
|
setting up specific packages/programs on pfSense:
|
||||||
|
|
||||||
|
- [Creating a Virtual Private Network with Tailscale](https://www.wundertech.net/how-to-set-up-tailscale-on-pfsense/)
|
||||||
|
- [Forwarding ports through your firewall](https://www.wundertech.net/pfsense-port-forwarding-setup-guide/)
|
||||||
|
- [Configuring VLANS, DHCP, and other stuff](https://itigic.com/how-to-configure-pfsense-internet-vlans-dhcp-dns-and-nat/)
|
||||||
|
|
||||||
|
*If you have chosen to stick with pfSense CE over pfSense+
|
38
source/_includes/guide-carousel.html
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{% assign carousel = page.carousels[include.id] %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="guideCarousel-{{ include.id }}" class="carousel slide card-body">
|
||||||
|
<h2 class="card-title">{{ carousel.title }}</h2>
|
||||||
|
<div class="carousel-indicators">
|
||||||
|
{% for step in carousel.steps %}
|
||||||
|
<button type="button" data-bs-target="guideCarousel-{{ include.id }}" data-bs-slide-to="{{ forloop.index0 }}"
|
||||||
|
{% if forloop.index0 == 0 %}
|
||||||
|
class="active" aria-current="true"
|
||||||
|
{% endif %}
|
||||||
|
aria-label="Slide {{i}}">
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-inner" style="overflow: visible;">
|
||||||
|
{% for step in carousel.steps %}
|
||||||
|
<div class="carousel-item{% if forloop.index0 == 0 %} active{% endif %}">
|
||||||
|
<img src="{{ step.image }}" class="d-block w-100" alt="{{ step.image-alt }}">
|
||||||
|
<div class="carousel-caption d-none d-md-block text-body-emphasis"
|
||||||
|
style="background: rgba(75, 75, 75, .7);">
|
||||||
|
<h5>Step {{ forloop.index }}</h5>
|
||||||
|
<p>{{ step.text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="carousel-control-prev" type="button" data-bs-target="#guideCarousel-{{ include.id }}" data-bs-slide="prev">
|
||||||
|
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Previous</span>
|
||||||
|
</button>
|
||||||
|
<button class="carousel-control-next" type="button" data-bs-target="#guideCarousel-{{ include.id }}" data-bs-slide="next">
|
||||||
|
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||||
|
<span class="visually-hidden">Next</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -23,8 +23,10 @@
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
<link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="/css/style.css" rel="stylesheet">
|
<link href="/css/style.css" rel="stylesheet">
|
||||||
<!-- Load fontawesome here for faster loadtimes: https://stackoverflow.com/a/35880730/9523246 -->
|
{% comment %}
|
||||||
|
Load fontawesome here for faster loadtimes: https://stackoverflow.com/a/35880730/9523246
|
||||||
|
{% endcomment %}
|
||||||
<script>
|
<script>
|
||||||
lazyLoadCSS('https://use.fontawesome.com/releases/v6.4.0/css/all.css');
|
lazyLoadCSS('https://use.fontawesome.com/releases/v6.4.2/css/all.css');
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
|
@ -9,7 +9,7 @@
|
||||||
<ul class="nav nav-pills">
|
<ul class="nav nav-pills">
|
||||||
{%- for link in site.data.navigation.header -%}
|
{%- for link in site.data.navigation.header -%}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link{% if page.url == link.link %} active{% endif %}" href="{{ link.link }}"
|
<a class="nav-link{% if page.url == link.link or link.directory and page.url contains link.link %} active{% endif %}" href="{{ link.link }}"
|
||||||
aria-current="page">{{ link.name }}</a>
|
aria-current="page">{{ link.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
{%- endfor -%}
|
{%- endfor -%}
|
||||||
|
|
7
source/_layouts/guide.html
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
layout: main
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
|
@ -1,36 +0,0 @@
|
||||||
---
|
|
||||||
layout: project
|
|
||||||
title: "Informinator"
|
|
||||||
description: News aggregator built with Python, AioHTTP, and Jinja
|
|
||||||
thumbnail_url: /assets/images/informinator.webp
|
|
||||||
project_url: https://informinator.owenryan.us/
|
|
||||||
# TODO: Uncomment this when source code is published
|
|
||||||
# source_url: https://code.owenryan.us/owenryan/informinator
|
|
||||||
SEO_tags: [Python, aiohttp, News]
|
|
||||||
---
|
|
||||||
|
|
||||||
Informinator is the one-stop site for all of your news needs! It aims to be a central hub that delivers the stories you
|
|
||||||
need to know while also funneling you to the proper news sources to get more information. Informinator is currently in
|
|
||||||
a very early beta and only sources news from Al Jazeera, BBC News, The Associated Press, and editorials from The
|
|
||||||
Guardian.
|
|
||||||
|
|
||||||
**Note: Informinator will be open-sourced in the next few weeks when the server is stable**
|
|
||||||
|
|
||||||
## Origins
|
|
||||||
|
|
||||||
During the spring 2023 semester, two separate classes I was taking had weekly news quizzes that checked that you had
|
|
||||||
been following current events. I decided that the best way to study for these quizzes was to create a news aggregator
|
|
||||||
to simplify the process of looking at several news outlets per day.
|
|
||||||
|
|
||||||
## Current issues
|
|
||||||
|
|
||||||
The first version of Informinator was hacked together over a weekend, so some design choices are suboptimal. Some of these include:
|
|
||||||
|
|
||||||
- All articles are loaded on page-load, even though most are hidden behind the carousel.
|
|
||||||
- Informinator's RSS parsing code only supports feeds from [RSSHub](https://docs.rsshub.app/en/).
|
|
||||||
- RSSHub's New York Times English feed is currently unusable due to [a parsing error](https://github.com/DIYgod/RSSHub/issues/12371).
|
|
||||||
- All articles are cached in Python when an in-memory database such as Redis would greatly improve performance.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
Informinator's public roadmap can be found [here](https://informinator.owenryan.us/roadmap).
|
|
|
@ -2,6 +2,8 @@
|
||||||
layout: project
|
layout: project
|
||||||
title: Adventures with old calculators
|
title: Adventures with old calculators
|
||||||
description: Shenanigans with graphing calculators my school was throwing away
|
description: Shenanigans with graphing calculators my school was throwing away
|
||||||
|
year: current
|
||||||
|
permalink: /projects/calculators
|
||||||
thumbnail_url: /assets/images/calculators.webp
|
thumbnail_url: /assets/images/calculators.webp
|
||||||
SEO_tags: [Calculator, Texas Instruments, TI82, Z80, Assembly, MS-DOS, Overclocking]
|
SEO_tags: [Calculator, Texas Instruments, TI82, Z80, Assembly, MS-DOS, Overclocking]
|
||||||
---
|
---
|
53
source/_projects/informinator.md
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
layout: project
|
||||||
|
title: "Informinator"
|
||||||
|
description: News aggregator built with React and TypeScript
|
||||||
|
year: current
|
||||||
|
permalink: /projects/informinator
|
||||||
|
thumbnail_url: /assets/images/informinator.webp
|
||||||
|
project_url: https://informinator.owenryan.us/
|
||||||
|
source_url: https://code.owenryan.us/owenryan/informinator
|
||||||
|
SEO_tags: [News, FOSS, React, RSS, TypeScript]
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note: Informinator will be open-sourced in the near future when the site leaves the testing stage**
|
||||||
|
|
||||||
|
Informinator is a web-based news aggregator that acts as a central place to catch up on current events.
|
||||||
|
|
||||||
|
## Origins
|
||||||
|
|
||||||
|
During the spring 2023 semester, two separate classes I was taking had weekly news quizzes that checked that you had
|
||||||
|
been following current events. I decided that the best way to study for these quizzes was to create a news aggregator
|
||||||
|
to simplify the process of looking at several news outlets per day.
|
||||||
|
|
||||||
|
The original version of Informinator was hacked together in a weekend and worked much differently than the current
|
||||||
|
version. It was written entirely in Python using Aiohttp as the webserver and jinja as the template engine. The server
|
||||||
|
did everything from Parse RSS to render HTML, which led to 5+ second load times as every RSS feed had to be fetched
|
||||||
|
before HTML was returned. It also only pulled feeds from RSSHub, and thus only featured stories from BBC News,
|
||||||
|
Al jazeera, The Associated Press, and Op-eds from The Guardian.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Informinator aims to be a privacy-friendly solution for news aggregation. Some information is inadvertently collected to
|
||||||
|
prevent spam, but it can be entirely avoided by self-holding your own instance of Informinator.
|
||||||
|
|
||||||
|
The official Informinator instance collects the following information
|
||||||
|
|
||||||
|
- IP address when you load the page - This is logged by the webserver and used to identify Denial-Of-Service (DOS) and
|
||||||
|
similar attacks.
|
||||||
|
- IP address and RSS feed URL when using the official informinator proxy to load articles - Again used for detecting
|
||||||
|
abuse.
|
||||||
|
|
||||||
|
## Current issues
|
||||||
|
|
||||||
|
The first version of Informinator was hacked together over a weekend, so some design choices are suboptimal. Some of these include:
|
||||||
|
|
||||||
|
- All articles are loaded on page-load, even though most are hidden behind the carousel.
|
||||||
|
- Informinator's RSS parsing code only supports feeds from [RSSHub](https://docs.rsshub.app/en/).
|
||||||
|
- RSSHub's New York Times English feed is currently unusable due
|
||||||
|
to [a parsing error](https://github.com/DIYgod/RSSHub/issues/12371).
|
||||||
|
- All articles are cached in Python when an in-memory database such as Redis would greatly improve performance.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Informinator's public roadmap can be found [here](https://informinator.owenryan.us/roadmap).
|
|
@ -2,9 +2,11 @@
|
||||||
layout: project
|
layout: project
|
||||||
title: "Jambox"
|
title: "Jambox"
|
||||||
description: "Discord music bot built with Hikari and Lavalink"
|
description: "Discord music bot built with Hikari and Lavalink"
|
||||||
|
year: current
|
||||||
|
permalink: /projects/jambox
|
||||||
project_url: https://discord.com/api/oauth2/authorize?client_id=1102705272805404722&permissions=4298394880&scope=applications.commands%20bot
|
project_url: https://discord.com/api/oauth2/authorize?client_id=1102705272805404722&permissions=4298394880&scope=applications.commands%20bot
|
||||||
thumbnail_url: /assets/images/jambox.webp
|
thumbnail_url: /assets/images/jambox.webp
|
||||||
SEO_tags: [Discord, Python, Hikari, Lavalink]
|
SEO_tags: [ Discord, Python, Hikari, Lavalink ]
|
||||||
---
|
---
|
||||||
|
|
||||||
**Note:** This project is currently on haitus due to a core Python library becoming deprecated.
|
**Note:** This project is currently on haitus due to a core Python library becoming deprecated.
|
||||||
|
|
|
@ -2,10 +2,12 @@
|
||||||
layout: project
|
layout: project
|
||||||
title: This website
|
title: This website
|
||||||
description: The self-built site using Jekyll, Bootstrap, TypeScript, and more!
|
description: The self-built site using Jekyll, Bootstrap, TypeScript, and more!
|
||||||
|
year: current
|
||||||
|
permalink: /projects/website
|
||||||
thumbnail_url: /assets/images/website.webp
|
thumbnail_url: /assets/images/website.webp
|
||||||
project_url: https://owenryan.us/
|
project_url: https://owenryan.us/
|
||||||
source_url: https://code.owenryan.us/owenryan/owenryan.us
|
source_url: https://code.owenryan.us/owenryan/owenryan.us
|
||||||
SEO_tags: [web, html, css, jekyll]
|
SEO_tags: [Website, HTML, CSS, Jekyll]
|
||||||
---
|
---
|
||||||
|
|
||||||
After squatting on this domain for 7 years, I finally stopped procrastinating and decided to create a website.
|
After squatting on this domain for 7 years, I finally stopped procrastinating and decided to create a website.
|
||||||
|
@ -14,8 +16,9 @@ Here is everything I used to turn my visions into reality.
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
This website was created using the [Jekyll](https://jekyllrb.com/) static site generator, which runs on the
|
This website was created using the [Jekyll](https://jekyllrb.com/) static site generator, which runs on the
|
||||||
[Liquid](https://shopify.github.io/liquid/) template engine. Jekyll supports writing posts in [Markdown](https://en.wikipedia.org/wiki/Markdown), which makes writing simple pages
|
[Liquid](https://shopify.github.io/liquid/) template engine. Jekyll supports writing posts in
|
||||||
like this one much easier. On the other hand, using plain markdown makes embedding videos and icons much more difficult.
|
[Markdown](https://en.wikipedia.org/wiki/Markdown), which makes writing simple pages like this one much easier. On the
|
||||||
|
other hand, using plain markdown makes embedding videos and icons much more difficult.
|
||||||
|
|
||||||
## Theme
|
## Theme
|
||||||
|
|
||||||
|
@ -39,3 +42,11 @@ so compiling to JavaScript has to be done manually.
|
||||||
|
|
||||||
Jekyll produces static HTML and CSS files, meaning that they can be hosted anywhere. I chose to host this website using
|
Jekyll produces static HTML and CSS files, meaning that they can be hosted anywhere. I chose to host this website using
|
||||||
the [Apache webserver](https://httpd.apache.org/) because it's reliable and has better documentation than NGINX.
|
the [Apache webserver](https://httpd.apache.org/) because it's reliable and has better documentation than NGINX.
|
||||||
|
|
||||||
|
## DOOM Easter Egg
|
||||||
|
|
||||||
|
You might have noticed the spaghetti monster on the social media panel on the front page. I had no idea what to put
|
||||||
|
there, so I linked to a page with a built-in x86 emulator ([v86](https://github.com/copy/v86)), that runs a super
|
||||||
|
lightweight Linux image built with Buildroot that boots straight into the original DOOM via [Chocolate
|
||||||
|
Doom](https://www.chocolate-doom.org/wiki/index.php/Chocolate_Doom). [Feel free to check out the buildroot
|
||||||
|
configuration](https://code.owenryan.us/owenryan/buildroot-doom)
|
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
// Remove bullet points from the about page
|
// Remove bullet points from the about page
|
||||||
// https://stackoverflow.com/questions/1027354/i-need-an-unordered-list-without-any-bullets
|
// https://stackoverflow.com/questions/1027354/i-need-an-unordered-list-without-any-bullets
|
||||||
main li
|
.remove-bullet-points li
|
||||||
list-style-type: none
|
list-style-type: none
|
||||||
padding-left: 2em
|
padding-left: 2em
|
||||||
text-indent: -2em
|
text-indent: -1.25em
|
||||||
|
|
|
@ -32,10 +32,12 @@ so [contact me](/contact) if you are interested!
|
||||||
Skills that I am still working on improving
|
Skills that I am still working on improving
|
||||||
|
|
||||||
- <i class="fa-brands fa-docker"></i> Building Docker containers for Python projects
|
- <i class="fa-brands fa-docker"></i> Building Docker containers for Python projects
|
||||||
- <i class="fa-solid fa-microchip"></i> C
|
- <i class="fa-solid fa-microchip"></i> C/C++
|
||||||
- <i class="fa-solid fa-memory"></i> C++
|
- <i class="fa-brands fa-java"></i> Java
|
||||||
|
- <i class="fa-solid fa-book"></i> Don't worry, I read Design Patterns
|
||||||
- <i class="fa-solid fa-chart-line"></i> Metrics aggregation using Prometheus and Grafana
|
- <i class="fa-solid fa-chart-line"></i> Metrics aggregation using Prometheus and Grafana
|
||||||
- <i class="fa-brands fa-square-js"></i> JavaScript/TypeScript
|
- <i class="fa-brands fa-square-js"></i> JavaScript/TypeScript
|
||||||
|
- <i class="fa-brands fa-react"></i> Experience with React & Next.js
|
||||||
- <i class="fa-solid fa-pen-ruler"></i> Frontend programming & Design
|
- <i class="fa-solid fa-pen-ruler"></i> Frontend programming & Design
|
||||||
- <i class="fa-brands fa-html5"></i> HTML
|
- <i class="fa-brands fa-html5"></i> HTML
|
||||||
- <i class="fa-brands fa-css3-alt"></i> CSS/SASS
|
- <i class="fa-brands fa-css3-alt"></i> CSS/SASS
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
// This code de-obfuscates my email and displays it on screen when the button is clicked
|
// This code de-obfuscates my email and displays it on screen when the button is clicked
|
||||||
// It's not a perfect solution, but it should stop be enough to stop email scraping
|
// It's not a perfect solution, but it should stop be enough to stop email scraping
|
||||||
// Email encoded in base64. This might be changed in the future to prevent scraping targeting base64 strings
|
// Email encoded in base64. This might be changed in the future to prevent scraping targeting base64 strings
|
||||||
const email = "Y29udGFjdEBvd2Vucnlhbi51cwo=";
|
var email = "Y29udGFjdEBvd2Vucnlhbi51cwo=";
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
// Get document elements
|
// Get document elements
|
||||||
const button = document.querySelector("#emailButton");
|
var button = document.querySelector("#emailButton");
|
||||||
const emailDiv = document.querySelector("#email");
|
var emailDiv = document.querySelector("#email");
|
||||||
// Decode the email string and insert a mailto link into the DOM, then disable the button
|
// Decode the email string and insert a mailto link into the DOM, then disable the button
|
||||||
button.addEventListener("click", function () {
|
button.addEventListener("click", function () {
|
||||||
const decodedEmail = atob(email);
|
var decodedEmail = atob(email);
|
||||||
emailDiv.insertAdjacentHTML("beforeend", "<div class=\"col\"><a href=\"mailto:".concat(decodedEmail, "\"><strong>").concat(decodedEmail, "</strong></a></div>"));
|
emailDiv.insertAdjacentHTML("beforeend", "<div class=\"col\"><a href=\"mailto:".concat(decodedEmail, "\"><strong>").concat(decodedEmail, "</strong></a></div>"));
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
BIN
source/assets/images/guides/pfsense/black.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
source/assets/images/guides/pfsense/cli-menu.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
source/assets/images/guides/pfsense/pfsense-login.png
Normal file
After Width: | Height: | Size: 54 KiB |
BIN
source/assets/images/guides/pfsense/pfsense-package-confirm.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
source/assets/images/guides/pfsense/pfsense-package-install.png
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
source/assets/images/guides/pfsense/pfsense-packages.png
Normal file
After Width: | Height: | Size: 159 KiB |
BIN
source/assets/images/guides/pfsense/setup-boot.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
source/assets/images/guides/pfsense/setup-bootloader.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
source/assets/images/guides/pfsense/setup-complete.png
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
source/assets/images/guides/pfsense/setup-confirm.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
source/assets/images/guides/pfsense/setup-copyright.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
source/assets/images/guides/pfsense/setup-installing.png
Normal file
After Width: | Height: | Size: 9.9 KiB |
BIN
source/assets/images/guides/pfsense/setup-partitioning.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
source/assets/images/guides/pfsense/setup-probe.png
Normal file
After Width: | Height: | Size: 8.8 KiB |
BIN
source/assets/images/guides/pfsense/setup-welcome.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
source/assets/images/guides/pfsense/setup-zfs-drives.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
source/assets/images/guides/pfsense/setup-zfs-menu-install.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
source/assets/images/guides/pfsense/setup-zfs-menu-pool.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
source/assets/images/guides/pfsense/setup-zfs-type.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
source/assets/images/guides/pfsense/snort-alerts.png
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
source/assets/images/guides/pfsense/snort-globalconfig1.png
Normal file
After Width: | Height: | Size: 156 KiB |
BIN
source/assets/images/guides/pfsense/snort-globalconfig2.png
Normal file
After Width: | Height: | Size: 172 KiB |
BIN
source/assets/images/guides/pfsense/snort-interfaces-empty.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
source/assets/images/guides/pfsense/snort-interfaces-off.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
source/assets/images/guides/pfsense/snort-interfaces-on.png
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
source/assets/images/guides/pfsense/topology.png
Normal file
After Width: | Height: | Size: 119 KiB |
1
source/assets/images/guides/pfsense/webp-to-png.sh
Normal file
|
@ -0,0 +1 @@
|
||||||
|
find "." -type f -name "*.png" -exec sh -c 'convert "$0" "${0%.png}.webp"' {} \;
|
BIN
source/assets/images/guides/pfsense/wizard-general.png
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
source/assets/images/guides/pfsense/wizard-lan.png
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
source/assets/images/guides/pfsense/wizard-password.png
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
source/assets/images/guides/pfsense/wizard-timezone.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
source/assets/images/guides/pfsense/wizard-wan.png
Normal file
After Width: | Height: | Size: 128 KiB |
BIN
source/assets/images/guides/pfsense/wizard-welcome.png
Normal file
After Width: | Height: | Size: 45 KiB |
|
@ -1,11 +1,10 @@
|
||||||
// JS Code that rotates the gradient on the content box on the front page
|
// JS Code that rotates the gradient on the content box on the front page
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const mainBox = document.querySelector("#rotating-gradient");
|
var mainBox = document.querySelector("#rotating-gradient");
|
||||||
let angle = 0;
|
var angle = 0;
|
||||||
// Rotate the gradient 1 degree every 100ms
|
// Rotate the gradient 1 degree every 100ms
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
const gradient = "linear-gradient(".concat(angle, "deg, #1E1F46, #404040)");
|
mainBox.style.background = "linear-gradient(".concat(angle, "deg, #1E1F46, #404040)");
|
||||||
mainBox.style["background"] = gradient;
|
|
||||||
angle += 1;
|
angle += 1;
|
||||||
if (angle >= 360) {
|
if (angle >= 360) {
|
||||||
angle = 0;
|
angle = 0;
|
||||||
|
|
|
@ -6,8 +6,7 @@ document.addEventListener("DOMContentLoaded", (): void => {
|
||||||
|
|
||||||
// Rotate the gradient 1 degree every 100ms
|
// Rotate the gradient 1 degree every 100ms
|
||||||
setInterval((): void => {
|
setInterval((): void => {
|
||||||
const gradient: string = `linear-gradient(${angle}deg, #1E1F46, #404040)`;
|
mainBox.style.background = `linear-gradient(${angle}deg, #1E1F46, #404040)`;
|
||||||
mainBox.style["background"] = gradient;
|
|
||||||
angle += 1;
|
angle += 1;
|
||||||
if (angle >= 360) {
|
if (angle >= 360) {
|
||||||
angle = 0
|
angle = 0
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Some useful functions used throughout my code
|
// Some useful functions used throughout my code
|
||||||
// Based on this SO answer https://stackoverflow.com/a/35880730/9523246
|
// Based on this SO answer https://stackoverflow.com/a/35880730/9523246
|
||||||
function lazyLoadCSS(url) {
|
function lazyLoadCSS(url) {
|
||||||
const css = document.createElement('link');
|
var css = document.createElement('link');
|
||||||
css.href = url;
|
css.href = url;
|
||||||
css.rel = 'stylesheet';
|
css.rel = 'stylesheet';
|
||||||
css.type = 'text/css';
|
css.type = 'text/css';
|
||||||
|
|
|
@ -11,6 +11,11 @@ permalink: /contact
|
||||||
<div id="email" class="row d-inline-flex">
|
<div id="email" class="row d-inline-flex">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<button id="emailButton" type="button" class="btn btn-primary">Get contact email</button>
|
<button id="emailButton" type="button" class="btn btn-primary">Get contact email</button>
|
||||||
|
<noscript>
|
||||||
|
The "get email" button uses JavaScript to decode the email. I respect your choice to disable JavaScript.
|
||||||
|
My email is <strong>contact</strong> at this domain. (Sorry for the wording I really don't want it to be
|
||||||
|
scraped.
|
||||||
|
</noscript>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
22
source/guides.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
layout: main
|
||||||
|
permalink: /guides/index.html
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 g-4">
|
||||||
|
{%- for guide in site.guides -%}
|
||||||
|
<div class="col p-2">
|
||||||
|
<a class="card" href="{{ guide.url }}" style="text-decoration: none;">
|
||||||
|
{%- if guide.thumbnail_url -%}
|
||||||
|
<img src="{{ guide.thumbnail_url }}" class="card-img-top" alt="{{ guide.title }} thumbnail">
|
||||||
|
{%- endif -%}
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-body-emphasis">{{ guide.title }}</h3>
|
||||||
|
<p class="card-text">{{ guide.description }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -3,7 +3,8 @@ layout: main
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="container my-5">
|
<div class="container my-5">
|
||||||
<div id="rotating-gradient" class="col-7 p-5 text-center rounded-3 position-absolute top-50 start-50 translate-middle">
|
<div id="rotating-gradient"
|
||||||
|
class="col-7 p-5 text-center rounded-3 position-absolute top-50 start-50 translate-middle">
|
||||||
<h1 class="text-body-emphasis">Hello there!</h1>
|
<h1 class="text-body-emphasis">Hello there!</h1>
|
||||||
<p class="col-lg-8 mx-auto fs-5 text-muted">Welcome to my website!</p>
|
<p class="col-lg-8 mx-auto fs-5 text-muted">Welcome to my website!</p>
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
layout: main
|
layout: main
|
||||||
permalink: /projects
|
permalink: /projects/index.html
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{%- for post in site.posts -%}
|
{%- for post in site.projects -%}
|
||||||
<div class="col p-2">
|
<div class="col p-2">
|
||||||
<a class="card project-card" href="{{ post.url }}" style="text-decoration: None;">
|
<a class="card project-card" href="{{ post.url }}" style="text-decoration: None;">
|
||||||
{%- if post.thumbnail_url -%}
|
{%- if post.thumbnail_url -%}
|
||||||
|
|